Software Architecture, DDD, CQRS and Event-Driven Systems
Dependency direction: everything points *inwards*.
Concept
Layered / N-tier Presentation → business → data access layers (tiers when deployed separately)
Clean / Onion / Hexagonal (ports & adapters) The domain is at the centre with no dependencies; application use cases around it; infrastructure (DB, CAN, HTTP) and UI at the edge implement interfaces (ports) defined inside Modular monolith
One deployable, strictly separated modules with their own data — often the best starting point
Independently deployable services per business capability, communicating over HTTP/gRPC or messages
Components react to events published on a broker Dependency direction: everything points *inwards*. The domain doesn't reference EF Core or ASP.NET Core; infrastructure implements domain/application interfaces (ICampaignRepository). Project boundaries (separate .csproj files: Diag.Domain, Diag.Application, Diag.Infrastructure, Diag.Api) let the compiler enforce this. DTOs cross boundaries; domain models stay inside.
Ubiquitous language Code uses the experts' words: *campaign*, *VIN*, *flash*, *DTC* Bounded context
A boundary within which a model is consistent (Diagnostics vs Fleet Management vs Billing each have their own "Vehicle")
Identity that persists through changes (UpdateCampaign with an Id) Value object
Defined by its values, immutable, self-validating (Vin, FirmwareVersion) Aggregate / aggregate root
A consistency boundary; all changes go through the root, which enforces invariants
Collection-like persistence for aggregates Domain service
Domain logic that doesn't belong to one entity Domain events
Something meaningful happened (VehicleUpdated); other parts react
Command Query Responsibility Segregation separates commands (change state, return little) from queries (return data, change nothing). Handlers per command/query keep use cases small. The read side can use a separate, denormalized read model updated from events — fast queries, but eventual consistency and more moving parts. Use CQRS where reads and writes differ strongly (dashboards over complex domains); a plain CRUD service doesn't need it.
Event-Driven Programming and Microservices Publishers emit events to a message broker (RabbitMQ, Azure Service Bus, Kafka); consumers handle them asynchronously.
Brokers deliver at least once → consumers must be idempotent (remember processed message IDs).
Failures are retried; messages that keep failing go to a dead-letter queue for inspection instead of blocking the queue.
The outbox pattern stores events in the same database transaction as the state change, then publishes them — no lost or phantom events.
Microservices add: service boundaries (per bounded context), API communication (REST/gRPC), service discovery, centralized configuration, distributed logging and tracing (Module 48), resilience (Module 58), message queues, eventual consistency (sagas instead of distributed transactions) and containerization (Module 59). They cost operational complexity — start with a modular monolith unless you need independent scaling/deployment.
flowchart TB
UI["Presentation: API, WPF, CLI"] --> APP["Application: use cases, commands, queries"]
APP --> DOM["Domain: entities, value objects, aggregates, domain events"]
INF["Infrastructure: EF Core, CAN driver, message broker, HTTP clients"] --> APP
INF --> DOMWhy It Matters
Over-the-air update platforms, fleet diagnostics and test data platforms are exactly the systems where these ideas pay off — and where getting them wrong (lost events, duplicate processing, a "distributed monolith") is expensive.
Syntax
public sealed record Vin { public static Vin Create(string v) => v.Length == 17 ? new(v) : throw new DomainException(...); }
public record RecordVehicleUpdated(Guid CampaignId, Vin Vin, FirmwareVersion Installed); // command
public record GetCampaignProgress(Guid CampaignId); // query
campaign.RecordUpdate(vin, version); // aggregate root enforces invariants and raises VehicleUpdatedExample
56.1 Value objects, an aggregate, CQRS handlers, a read model, and reliable message consumption
The layers are marked with comments; in a real solution they would be separate projects.
// ====================== Domain layer (no dependencies) ======================
// Value objects: immutable, compared by value, validated on creation
var vin = Vin.Create("EHBAUREV250000001");
var version = FirmwareVersion.Parse("2.4.1");
Console.WriteLine($"value objects: {vin}, {version}, equal by value: {FirmwareVersion.Parse("2.4.1") == version}");
try { Vin.Create("TOO-SHORT"); } catch (DomainException ex) { Console.WriteLine($"invalid value object rejected: {ex.Message}"); }
// ====================== Composition root (normally Program.cs / DI) ======================
var bus = new InMemoryEventBus();
var repository = new InMemoryCampaignRepository();
var readModel = new CampaignDashboard(); // CQRS read side, updated from events
bus.Subscribe<CampaignStarted>(e => readModel.Apply(e));
bus.Subscribe<VehicleUpdated>(e => readModel.Apply(e));
bus.Subscribe<VehicleUpdated>(new NotificationHandler().Handle);
var commands = new CampaignCommandHandlers(repository, bus);
var queries = new CampaignQueryHandlers(readModel);
// ====================== Commands change state; queries only read ======================
Guid campaignId = commands.Handle(new StartCampaign("OT
…Expected Result
The aggregate refused to update the same vehicle twice and rejected a vehicle outside the campaign. The query was answered from the read model built purely from events. In the messaging part, the transient timeout was retried and succeeded, the duplicate delivery was ignored, and the poison message went to the dead-letter queue after three attempts instead of blocking the queue forever.
value objects: EHBAUREV250000001, 2.4.1, equal by value: True
invalid value object rejected: VIN must have 17 characters, got 9
event published: CampaignStarted
event published: VehicleUpdated
notification: EHBAUREV250000001 now runs 2.5.0
aggregate invariant protected: EHBAUREV250000001 was already updated
aggregate invariant protected: EHBAUREV250000009 is not part of campaign OTA-VCU-2.5
query: OTA-VCU-2.5: 1/2 vehicles updated (50%)
TelemetryReceived: attempt 1 failed (TimeoutException), re-queued
TelemetryReceived: processed
PoisonMessage: attempt 1 failed (FormatException), re-queued
TelemetryReceived: duplicate ignored (idempotent consumer)
PoisonMessage: attempt 2 failed (FormatException), re-queued
PoisonMessage: attempt 3 failed, moved to dead-letter queue
dead-letter queue: PoisonMessage after 3 attempts (payload cannot be parsed)Common Mistakes
Anemic domain models: entities with only getters/setters and all rules in services.
Aggregates that are too large (the whole fleet) → contention; or rules spread across aggregates.
Referencing infrastructure (EF Core attributes, HTTP) from the domain layer.
Microservices sharing one database, or chatty synchronous calls between them (distributed monolith).
Non-idempotent consumers with at-least-once delivery; no dead-letter handling.
CQRS/event sourcing for simple CRUD.
Best Practices
Start with a modular monolith with clean boundaries; extract services when there's a clear reason.
Model invariants inside aggregates; keep aggregates small and reference others by ID.
Use value objects generously.
Outbox + idempotent consumers + dead-letter queues for messaging.
Make boundaries visible in the solution structure and enforce them (project references, architecture tests).
Interview Relevance
High for senior roles: "explain clean architecture", "entity vs value object?", "what is an aggregate root?", "what is CQRS and when is it worth it?", "how do you guarantee a message is processed exactly once?", "monolith vs microservices?", "what is eventual consistency?".
Practice Questions
Split the example into four projects (Domain, Application, Infrastructure, Console) with correct references.
Add a CancelCampaign command that is only allowed when fewer than 50% of vehicles are updated.
Implement an outbox table with EF Core and a background publisher.
Draw the bounded contexts of a diagnostic platform (devices, diagnostics, reporting, users).
Quick Quiz
Q1. (Multiple choice) Which object is compared by value and has no identity? A) Entity B) Aggregate root C) Value object D) Repository
Answer: C (verified: two FirmwareVersion.Parse("2.4.1") values are equal).
Q2. (True/False) In clean architecture the domain project references the infrastructure project.
Answer: False. Dependencies point inwards; infrastructure references the domain.
Q3. (Predict the output) A consumer receives the same message ID twice with an idempotency store.
Answer: The second delivery is ignored (verified: duplicate ignored).
Q4. (Find the bug) campaign.Vehicles[vin] = true; is called directly from an API controller.
Answer: It bypasses the aggregate root's invariants (not in campaign, already updated, version too old) and raises no domain event. Expose RecordUpdate instead and keep the collection private.
Coding Challenge
Write a Money-style value object Voltage (non-negative, max 1000 V, two decimals) with value equality, addition, and validation, and show that two equal voltages compare equal.
var a = Voltage.From(12.604);
var b = Voltage.From(12.6);
Console.WriteLine($"{a} == {b}: {a == b}");
Console.WriteLine($"sum: {a + Voltage.From(0.4)}");
try { Voltage.From(-1); } catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"rejected: {ex.ParamName} {ex.ActualValue}"); }
public readonly record struct Voltage
{
public decimal Volts { get; }
private Voltage(decimal volts) => Volts = volts;
public static Voltage From(double volts) =>
volts is < 0 or > 1000 ? throw new ArgumentOutOfRangeException(nameof(volts), volts, "0..1000 V")
: new Voltage(Math.Round((decimal)volts, 2));
public static Voltage operator +(Voltage x, Voltage y) => From((double)(x.Volts + y.Volts));
public override string ToString() => $"{Volts:F2} V";
}