Documentation

Architecture.md

Dynaplex Architecture

This document describes the architectural patterns and principles that form the structural foundation of Dynaplex. These patterns are not mere guidelines—they are enforced through tooling, contracts, and runtime boundaries.

Core Concepts

Components

A component is the fundamental unit of organization in Dynaplex. Components are self-contained, independently deployable units that provide specific capabilities through well-defined contracts.

Every component follows a consistent structure:

components/
└── catalog/
    ├── src/
    │   ├── Acsis.Dynaplex.Catalog/              # Implementation
    │   ├── Acsis.Dynaplex.Catalog.Abstractions/ # Contracts (DTOs, interfaces)
    │   ├── Acsis.Dynaplex.Catalog.Database/     # EF Core context, entities, migrations
    │   └── Acsis.Dynaplex.Catalog.ApiClient/    # Generated Kiota client
    └── tests/
        ├── Acsis.Dynaplex.Catalog.Tests.Unit/
        └── Acsis.Dynaplex.Catalog.Tests.Integration/

Project Roles

Project Purpose Dependencies
.Abstractions Defines the public contract: DTOs, interfaces, constants None (pure contracts)
Implementation The service implementation with controllers and business logic .Abstractions, .Database
.Database EF Core DbContext, entities, migrations .Abstractions (for shared types)
.ApiClient Auto-generated typed HTTP client (via Kiota) .Abstractions

Architectural Boundaries

Dynaplex enforces boundaries through physical separation:

  1. Process Isolation: Each component runs in its own process, managed by Aspire
  2. Database Isolation: Each component owns its database; no shared databases
  3. Contract-First Communication: Components communicate only through typed API clients
  4. No Shared State: No static singletons, no shared memory, no ambient context

These are not suggestions. They are enforced by:

  • Roslyn analyzers that fail the build on boundary violations
  • Runtime service discovery that prevents direct database access
  • Generated clients that make contract violations impossible

.NET Aspire Orchestration

Aspire is the orchestration layer that brings components to life. It manages:

  • Service Lifecycle: Starting, stopping, health checking
  • Service Discovery: Components find each other by name, not by URL
  • Resource Provisioning: Databases, caches, message brokers
  • Observability: Distributed tracing, metrics, structured logging

The AppHost

Each Dynaplex project has an AppHost that defines the service topology:

var builder = DistributedApplication.CreateBuilder(args);

// Resources
var postgres = builder.AddPostgres("postgres")
    .WithDataVolume();

// Components
var catalog = builder.AddProject<Projects.Catalog>("catalog")
    .WithReference(postgres);

var spatial = builder.AddProject<Projects.Spatial>("spatial")
    .WithReference(postgres)
    .WithReference(catalog);  // Service reference

builder.Build().Run();

Dynamic Port Assignment

Aspire dynamically assigns ports at runtime. Never hardcode port numbers. Components discover each other through service references, not URLs.

// WRONG: Hardcoded URL
var client = new HttpClient { BaseAddress = new Uri("http://localhost:5001") };

// RIGHT: Service discovery
var client = httpClientFactory.CreateClient("catalog");

Type-Safe Communication

Generated API Clients

Dynaplex uses Microsoft Kiota to generate strongly-typed HTTP clients from OpenAPI specifications:

  1. Each component publishes its OpenAPI specification
  2. At build time, Kiota generates a typed client in the .ApiClient project
  3. Consuming components reference the .ApiClient project
  4. Type mismatches are caught at compile time, not runtime
// Generated client provides type safety
var catalog = await catalogClient.Products.GetAsync();
// catalog is strongly typed - no deserialization guesswork

Contract Evolution

Because contracts are explicit and clients are generated:

  • Breaking changes fail the build immediately
  • API versioning is explicit and traceable
  • Consumers always match the producer's expectations

Database Patterns

Database-per-Service

Each component owns its database. This means:

  • No foreign keys across component boundaries
  • No shared tables between components
  • No direct SQL queries to another component's database

Cross-component data access happens through API calls, not SQL joins.

Entity Conventions

  • Guid primary keys: Almost all entities use Guid for IDs (aligned with the Prism/Passport system)
  • DateTimeOffset for timestamps: Never use DateTime for temporal data
  • Explicit relationships: Always define navigation properties and foreign keys

Migrations

Each component manages its own migrations through EF Core:

# Generate a migration for the Catalog component
dotnet ef migrations add AddProductCategory \
    --project components/catalog/src/Acsis.Dynaplex.Catalog.Database \
    --startup-project projects/bbu-rfid/src/Acsis.Dynaplex.Projects.BbuRfid

Observability

Built-In Telemetry

The ServiceDefaults project provides standardized observability:

  • Distributed Tracing: Automatic trace propagation across service calls
  • Structured Logging: Consistent log format with correlation IDs
  • Metrics: Service health, request latency, error rates
  • Health Checks: Readiness and liveness probes for each component

The Aspire Dashboard

During development, the Aspire Dashboard provides:

  • Real-time service status
  • Log aggregation
  • Trace visualization
  • Resource utilization

Resilience Patterns

Built-In Resilience

The ServiceDefaults project configures resilience for all HTTP clients:

  • Retry: Transient failures are automatically retried with exponential backoff
  • Timeout: Long-running requests are terminated
  • Circuit Breaker: Failing services are temporarily bypassed

Graceful Degradation

Components should handle unavailable dependencies gracefully:

try
{
    var product = await catalogClient.Products[id].GetAsync();
    return Ok(product);
}
catch (ServiceUnavailableException)
{
    return Ok(CachedProduct.GetFallback(id));
}

Enforcement

Roslyn Analyzers

The Acsis.Dynaplex.Strata.Analyzers project enforces architectural rules at compile time:

  • No direct database references across components
  • No hardcoded URLs or port numbers
  • Correct project dependency directions
  • Proper async/await usage

Runtime Enforcement

Service discovery ensures that components can only communicate through sanctioned channels. Direct network calls to other services' internal ports are not possible.

Evolution from Polylith

Dynaplex evolved from an architecture inspired by Polylith. Key differences:

Polylith Dynaplex
Single deployable Multiple services
Shared runtime Process isolation
Interface boundaries Network boundaries
Monolith at deploy Microservices

The core principles—clean boundaries, component reuse, explicit dependencies—remain. The implementation adapted to cloud-native requirements.


Architecture is not a diagram. It is the set of decisions that are expensive to change later. Dynaplex makes the right decisions easy and the wrong decisions impossible.