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:
- Process Isolation: Each component runs in its own process, managed by Aspire
- Database Isolation: Each component owns its database; no shared databases
- Contract-First Communication: Components communicate only through typed API clients
- 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:
- Each component publishes its OpenAPI specification
- At build time, Kiota generates a typed client in the
.ApiClientproject - Consuming components reference the
.ApiClientproject - 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
Guidfor IDs (aligned with the Prism/Passport system) - DateTimeOffset for timestamps: Never use
DateTimefor 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.