Domain-Driven Design
Reviewed & published by Brayan K
By the end of this lesson you'll be able to model a business in code using the domain experts' own language — building value objects that make invalid states impossible, entities with identity, and aggregates whose roots guard the rules that must always hold. This is how large C# systems stay correct as they grow.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Speak the ubiquitous language so code matches how the business talks
- Tell value objects (no identity, compared by value) from entities (have identity)
- Design aggregates with a root that protects invariants — rules that must always hold
- Use the repository pattern to load and save whole aggregates
- Raise domain events to decouple side effects from business logic
- Split a large model into bounded contexts with their own language
💡 Real-World Analogy
Imagine you're hired to build software for a warehouse, but you've never run one. So you sit with the warehouse manager and listen. They say "an order has line items", "you can't ship an order that hasn't been picked", "stock is reserved when an order is confirmed." Domain-Driven Design is simply this: you model the business in code using their words and their rules — not "rows" and "records", but Orders, Shipments and Stock. When the manager says "you can't confirm an empty order", that sentence becomes a method that literally refuses to do it. The code reads like a description of the business, so the experts can almost read it too.
📊 The Building Blocks at a Glance
| Pattern | Has identity? | Compared by | Use it for |
|---|---|---|---|
| Value Object | No | Its values | Money, Email, Address, Distance |
| Entity | Yes (an Id) | Identity (Id) | Customer, Product — things tracked over time |
| Aggregate | Yes (the root) | Root's identity | Order + its lines — a consistency boundary |
| Repository | — | — | Loading & saving whole aggregates |
The first question to ask about any concept: "do I care which one it is, or only what it is?" If you care which one (this exact customer), it's an entity. If you only care about the value (this amount of money), it's a value object.
1. The Ubiquitous Language
The ubiquitous language is a shared vocabulary that the developers and the domain experts both use — in conversation, in documents, and in the code itself. If the business says "confirm an order", you write order.Confirm(), not order.SetStatus(2). The payoff is that misunderstandings surface early and the code becomes a living description of the business. When the language in your classes drifts from the language in the meeting room, bugs hide in the gap.
Naming that matches the business
Compare two versions of the same idea. The first is technically fine but says nothing about the domain; the second is the domain, written down.
// ❌ Anaemic & technical — the rule is invisible
order.Status = 2;
order.Items.Add(item);
// ✅ Ubiquitous language — the rule is the method
order.Confirm(); // refuses to confirm an empty order
order.AddItem(product, price, qty);Read the second version aloud — it sounds like the warehouse manager describing the process. That is the goal of every name you choose.
2. Value Objects
A value object has no identity — it's defined entirely by its values, so two with the same values are interchangeable (10 GBP is 10 GBP). They're immutable (never change after creation) and self-validating (an invalid one can't exist). In C# a record is ideal: it's immutable by default and gives you value equality for free, so == compares contents, not references. Read this worked example and run it, then you'll build your own.
using System;
// A VALUE OBJECT has no identity — it is defined entirely by its values.
// Two banknotes both worth 10 GBP are interchangeable: you don't care WHICH
// note you hold, only how much it is worth. So 10 GBP == 10 GBP, full stop.
//
// A C# 'record' is the perfect tool: it is immutable (no setters) and gives
// you VALUE EQUALITY for free — two records with the same values are equal.
public record Money(decimal Amount, string Currency)
{
// The domain rules live INSIDE the type, so an invalid Money cannot exist.
public static Money Of(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Money cannot be negative");
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be a 3-letter code");
return new Money(amount, currency.ToUpperInvariant());
}
// Behaviour returns a NEW Money — the original is never mutated.
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new ArgumentException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:F2} {Currency}";
}
class Program
{
static void Main()
{
var a = Money.Of(10m, "GBP");
var b = Money.Of(10m, "gbp"); // normalised to "GBP" by the factory
// Value equality: the records match because their VALUES match,
// even though they are two separate objects in memory.
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
var different = Money.Of(25m, "GBP");
Console.WriteLine(a == different); // False
// Adding returns a brand-new Money; 'a' is unchanged (immutable).
var sum = a.Add(different);
Console.WriteLine(sum); // 35.00 GBP
Console.WriteLine(a); // 10.00 GBP (still the same)
}
}
// ✅ Expected output:
// True
// True
// False
// 35.00 GBP
// 10.00 GBPYour turn. The program below builds a Distance value object and compares two of them — it just needs the record keyword and the equality operator. Fill in the two ___ blanks, then run it.
using System;
// 🎯 YOUR TURN — build a Distance VALUE OBJECT, then compare two of them.
// A value object is immutable and compared BY VALUE, so 5 km == 5 km.
// 1) Make this a record so you get immutability + value equality for free.
public ___ Distance(decimal Kilometres) // 👉 the keyword is record
{
public override string ToString() => $"{Kilometres} km";
}
class Program
{
static void Main()
{
var trip1 = new Distance(5m);
var trip2 = new Distance(5m); // same value, separate object
var trip3 = new Distance(8m);
// 2) Compare trip1 and trip2 by VALUE (records support ==).
bool sameDistance = trip1 ___ trip2; // 👉 the equality operator ==
Console.WriteLine($"trip1 == trip2? {sameDistance}");
Console.WriteLine($"trip1 == trip3? {trip1 == trip3}");
// ✅ Expected output:
// trip1 == trip2? True
// trip1 == trip3? False
}
}3. Entities & Identity
An entity is the opposite of a value object: it has identity. Two customers called "Sam Lee" are different people, each with their own Id, and you compare entities by that identity — never by their attributes. An entity's data changes over time (a customer earns points), but its identity never does. Crucially, an entity should change its own state only through methods that enforce its invariants — the rules that must always be true.
using System;
// An ENTITY is the opposite of a value object: it HAS identity.
// Two customers named "Sam Lee" are NOT the same person — each has its own Id,
// and we compare entities by that Id, never by their attributes.
// An entity's data can change over time, but its identity never does.
public class Customer
{
public Guid Id { get; } // identity — set once, never changes
public string Name { get; private set; }
public int LoyaltyPoints { get; private set; }
public Customer(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Customer needs a name");
Id = Guid.NewGuid(); // a fresh, unique identity
Name = name;
LoyaltyPoints = 0;
}
// A method that ENFORCES AN INVARIANT (a rule that must always hold):
// loyalty points can never go negative. State only changes through here.
public void Redeem(int points)
{
if (points <= 0)
throw new ArgumentException("Redeem a positive amount");
if (points > LoyaltyPoints)
throw new InvalidOperationException("Not enough points");
LoyaltyPoints -= points; // safe: the rule was checked first
}
public void Earn(int points) => LoyaltyPoints += points;
}
class Program
{
static void Main()
{
var sam1 = new Customer("Sam Lee");
var sam2 = new Customer("Sam Lee"); // same name, DIFFERENT person
// Identity, not attributes: same name but not equal (different Ids).
Console.WriteLine(sam1.Id == sam2.Id); // False
sam1.Earn(100);
sam1.Redeem(30);
Console.WriteLine($"Points: {sam1.LoyaltyPoints}"); // Points: 70
// The invariant protects you: this is rejected, not silently allowed.
try { sam1.Redeem(999); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Not enough points
}
}Now you try. Finish the Thermostat entity so it has its own identity and a method that refuses any temperature outside 5–30. Fill in the two ___ blanks:
using System;
// 🎯 YOUR TURN — finish the Thermostat ENTITY.
// It has identity (an Id) and an invariant: temperature stays within 5-30.
public class Thermostat
{
public Guid Id { get; }
public int Temperature { get; private set; }
public Thermostat()
{
// 1) Give this entity its own unique identity.
Id = ___; // 👉 a brand-new id: Guid.NewGuid()
Temperature = 20;
}
// SetTo must ENFORCE the invariant: reject anything outside 5-30.
public void SetTo(int degrees)
{
// 2) If degrees is below 5 OR above 30, refuse it.
if (degrees < 5 ___ degrees > 30) // 👉 the logical OR operator ||
throw new ArgumentException("Temperature must be 5-30");
Temperature = degrees;
}
}
class Program
{
static void Main()
{
var t = new Thermostat();
t.SetTo(22);
Console.WriteLine($"Now: {t.Temperature}"); // Now: 22
try { t.SetTo(99); }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
// ✅ Expected output:
// Now: 22
// Temperature must be 5-30
}
}4. Aggregates, Roots & Invariants
An aggregate is a cluster of related objects you treat as a single unit whenever you change them — for example an Order together with its line items. One entity is the aggregate root: it's the only way in. Outside code never touches the children directly, so the root can guarantee its invariants always hold (you can't confirm an empty order; you can't change a confirmed one). The list of children stays private and is exposed only as a read-only view, so nobody can sneak a change past the rules. Read this and run it.
using System;
using System.Collections.Generic;
using System.Linq;
// An AGGREGATE is a cluster of related objects treated as one unit for changes.
// The AGGREGATE ROOT (Order) is the ONLY entry point: outside code never edits
// the line items directly, so the root can guarantee its invariants always hold.
public class Order
{
public Guid Id { get; } = Guid.NewGuid();
public bool IsConfirmed { get; private set; }
// The list is PRIVATE; outsiders only get a read-only view. They cannot
// add a line item behind the root's back, so rules can't be bypassed.
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public decimal Total => _lines.Sum(l => l.Price * l.Quantity);
// All changes go THROUGH the root, which enforces the rules.
public void AddItem(string product, decimal price, int quantity)
{
if (IsConfirmed)
throw new InvalidOperationException("Cannot change a confirmed order");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
_lines.Add(new OrderLine(product, price, quantity));
}
// INVARIANT: you cannot confirm an empty order.
public void Confirm()
{
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order");
IsConfirmed = true;
}
}
// A line item is a value object owned by the aggregate.
public record OrderLine(string Product, decimal Price, int Quantity);
class Program
{
static void Main()
{
var order = new Order();
// Confirming an EMPTY order is blocked by the invariant.
try { order.Confirm(); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Cannot confirm an empty order
order.AddItem("Keyboard", 45m, 1);
order.AddItem("Mouse", 20m, 2);
Console.WriteLine($"Total: {order.Total}"); // Total: 85
order.Confirm();
Console.WriteLine($"Confirmed: {order.IsConfirmed}"); // Confirmed: True
// After confirmation the aggregate is locked — changes are refused.
try { order.AddItem("Late add", 5m, 1); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Cannot change a confirmed order
}
}🔎 Deep Dive: where does an aggregate end?
The boundary of an aggregate is a consistency boundary: everything inside it must be valid together in a single transaction. Ask "if I change A, must B change in the same breath to stay correct?" If yes, they belong in the same aggregate. If no, they're separate aggregates that reference each other by Id, not by object reference.
So an Order owns its OrderLines (they're meaningless apart from the order), but it references the Customer by CustomerId — a customer lives its own life and changes on its own schedule. This keeps aggregates small and transactions fast.
public class Order
{
public Guid CustomerId { get; } // ✅ reference another aggregate by Id
private List<OrderLine> _lines; // ✅ own your children directly
// public Customer Customer { get; } // ❌ don't hold the whole customer object
}5. Repositories
A repository gives the domain the illusion of an in-memory collection of aggregates: you ask for an Order by Id and get the whole thing back, with no SQL or Entity Framework leaking into your business logic. There's one repository per aggregate, and it always loads and saves the aggregate as a unit. The application code depends only on the interface — a domain concept — so the domain layer stays pure and testable. This example needs a database to run, so read it rather than running it.
// A REPOSITORY gives you the illusion of an in-memory collection of aggregates.
// It hides the database: the domain asks for an Order by Id and gets one back,
// with no SQL or EF Core leaking into the business logic. One repo per AGGREGATE.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id); // load a whole aggregate
Task AddAsync(Order order); // persist a new aggregate
Task SaveChangesAsync(); // commit the unit of work
}
// The application layer depends only on the INTERFACE (a domain concept),
// never on the concrete data-access class. That keeps the domain pure.
public class PlaceOrderHandler
{
private readonly IOrderRepository _orders;
public PlaceOrderHandler(IOrderRepository orders) => _orders = orders;
public async Task HandleAsync(Guid orderId)
{
var order = await _orders.GetByIdAsync(orderId)
?? throw new InvalidOperationException("Order not found");
order.Confirm(); // pure domain behaviour
await _orders.SaveChangesAsync(); // infrastructure does the rest
}
}6. Domain Events
A domain event records something that happened in the domain, named in the past tense — OrderConfirmed, FundsWithdrawn. The aggregate raises the event to announce a fact, without knowing who reacts to it. That decouples side effects — sending an email, writing an audit log, reserving stock — from the core rules. The one discipline: dispatch events only after the change is saved, or a failed save will have already fired emails for an order that doesn't exist. This needs a dispatcher to run, so read it.
// A DOMAIN EVENT records something that HAPPENED in the domain, in the past
// tense (OrderConfirmed, FundsWithdrawn). It lets the aggregate announce a fact
// without knowing — or caring — who reacts to it. That decouples side effects
// (emails, audit logs, stock updates) from the core business rules.
public interface IDomainEvent { DateTime OccurredAt { get; } }
public record OrderConfirmed(Guid OrderId) : IDomainEvent
{
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
public class Order
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> Events => _events.AsReadOnly();
public bool IsConfirmed { get; private set; }
public void Confirm()
{
if (IsConfirmed) return;
IsConfirmed = true;
// The aggregate RAISES the event; it does not send the email itself.
_events.Add(new OrderConfirmed(Id: Guid.NewGuid()));
}
}
// A separate handler reacts AFTER the aggregate is saved. Dispatch events only
// once the change is committed — otherwise a failed save fires emails for nothing.
public interface IHandle<T> where T : IDomainEvent { Task On(T e); }
public class SendReceiptEmail : IHandle<OrderConfirmed>
{
public Task On(OrderConfirmed e) => Task.CompletedTask; // ... send the email
}7. Bounded Contexts
In a big system, one word means different things to different teams. To Sales, a "Customer" is a lead with a phone number and a deal size; to Support, a "Customer" is a ticket history; to Billing, it's a payment method and an invoice address. A bounded context is an explicit boundary — usually one module, service, or microservice — inside which a model and its ubiquitous language are consistent. Trying to make one giant Customer class serve every team produces a bloated mess that pleases no one. Instead, each context keeps its own focused model and they integrate at the edges (by Id, events, or APIs).
🔎 Deep Dive: one word, three models
Don't fight this — embrace it. The same Guid identifies the customer across contexts, but each context models only what it needs.
// Sales context
public class Customer { public string Name; public decimal DealSize; }
// Support context — same person, different concerns
public class Customer { public List<Ticket> Tickets; public string Tier; }
// Billing context
public class Customer { public PaymentMethod Card; public Address Invoice; }This is exactly why DDD pairs so naturally with the next lesson, microservices: a well-drawn bounded context is often a service boundary too.
Pro Tips
- 💡 Push behaviour into the model: a rich domain type with methods like order.Confirm() beats a bag of public setters edited by a service. If your classes are all data and no behaviour, that's an anaemic model.
- 💡 Keep aggregates small: if two things don't need to be consistent in the same transaction, split them into separate aggregates linked by Id, not object reference.
- 💡 Make invariants impossible to break: private setters, private collections exposed as IReadOnlyList, and factory methods that validate — so an invalid object can never be constructed.
- 💡 Use value objects to kill primitive obsession: a Money or Email type carries its own rules; a bare decimal or string carries none.
- 💡 One repository per aggregate root — never a repository for a child entity. You load and save the whole aggregate together.
Common Mistakes (and the fix)
- The anaemic domain model: classes with only public getters/setters and zero behaviour, with all the logic dumped in "service" classes. The fix: move the rules into the entity as methods, so the object can never enter an invalid state.
- Leaking invariants out of the aggregate: exposing the children as a mutable List so callers do order.Lines.Add(...), bypassing every check. The fix: keep the list private, expose IReadOnlyList, and add via order.AddItem(...).
- Giant aggregates: one root that owns hundreds of entities, so every change locks a huge graph and transactions crawl. The fix: split along consistency boundaries and reference other aggregates by Id.
- Primitive obsession: passing string email and decimal amount everywhere, so nothing validates them and the same bug is re-checked in ten places. The fix: introduce Email and Money value objects that validate once, on creation.
- Dispatching events before saving: handlers send emails, then the database save fails and rolls back. The fix: raise events on the aggregate, but only dispatch them after the transaction commits.
📋 Quick Reference
| Concept | In C# | Notes |
|---|---|---|
| Value object | public record Money(decimal Amount, string Ccy); | Immutable + value equality |
| Entity identity | public Guid Id { get; } | Set once, compare by Id |
| Protected child list | _lines.AsReadOnly() | No edits behind the root |
| Enforce an invariant | if (empty) throw ...; | Guard inside a method |
| Reference another aggregate | public Guid CustomerId { get; } | By Id, not by object |
| Repository | Task<Order?> GetByIdAsync(Guid id); | One per aggregate root |
| Domain event | record OrderConfirmed(Guid Id) : IDomainEvent; | Past tense, dispatch after save |
Frequently Asked Questions
Q: How do I decide if something is an entity or a value object?
Ask whether you care which one it is. If you need to track this specific thing over time (this customer, this order), it has identity — it's an entity. If you only care about its value and any two with the same value are interchangeable (this amount of money, this email address), it's a value object.
Q: Isn't putting all this logic in the model just overkill for a CRUD app?
For a simple forms-over-data app, yes — DDD's tactical patterns shine when the domain has real rules and complexity. Use the full toolkit where the business logic is rich; for plain CRUD a thin model is fine. The skill is knowing which part of your system is which.
Q: Why can't an aggregate just reference another aggregate as a full object?
Because that blurs consistency boundaries: you'd load and lock a huge object graph for every change, and it's unclear which aggregate's invariants apply. Referencing by Id keeps each aggregate independently loadable and its transaction small.
Q: Are domain events the same as integration events / messages?
Not quite. A domain event is internal to one bounded context and handled in-process after the save. An integration event is published across contexts (often over a message bus) to tell other services something happened. They're related, but live at different layers.
Q: Where do repositories and the database fit with Clean Architecture?
The repository interface lives in the domain (it's a domain concept), and the concrete implementation that talks to Entity Framework lives in the infrastructure layer. The domain depends on the interface only — exactly the dependency rule you learned in Clean Architecture.
Mini-Challenge: a ShoppingCart Aggregate
No blanks this time — just a brief and an outline. Build a ShoppingCart aggregate whose AddItem and Checkout enforce a rule: you can't check out an empty cart, and you can't add items after checkout. Keep the item list private and expose it read-only. Run it and check your output against the expected lines in the comments.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 MINI-CHALLENGE: Build a ShoppingCart AGGREGATE
// 1. Field: a PRIVATE List of CartItem, exposed only as IReadOnlyList.
// 2. AddItem(string name, decimal price): refuse it once the cart is checked out.
// 3. A bool IsCheckedOut property with a private setter.
// 4. Checkout(): throw if the cart is EMPTY, otherwise set IsCheckedOut = true.
// (This is the invariant — you can't check out an empty cart.)
// 5. A Total property that sums the item prices.
//
// In Main: try Checkout() on an empty cart (expect a refusal), then add two
// items, print the total, Checkout(), and print IsCheckedOut.
//
// ✅ Expected output (e.g. items at 30 and 20):
// Cannot checkout an empty cart
// Total: 50
// Checked out: True
public record CartItem(string Name, decimal Price);
public class ShoppingCart
{
// your private list, properties and methods here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ The ubiquitous language makes code read like the business describes itself
- ✅ Value objects (records) have no identity, are immutable, and compare by value
- ✅ Entities have identity (an Id) and change state only through invariant-enforcing methods
- ✅ An aggregate root is the only entry point and guards the cluster's invariants
- ✅ Repositories load and save whole aggregates — one per aggregate root
- ✅ Domain events decouple side effects; dispatch them only after saving
- ✅ Bounded contexts let one word mean different things in different parts of the system
Practice quiz
What is the 'ubiquitous language' in DDD?
- A programming language used by all teams
- The English language only
- A shared vocabulary that developers and domain experts both use, in conversation and in the code
- A universal database schema
Answer: A shared vocabulary that developers and domain experts both use, in conversation and in the code. The ubiquitous language is a shared vocabulary so that, for example, 'confirm an order' becomes order.Confirm() in code rather than order.SetStatus(2).
How is a value object compared?
- By its values — two with the same values are equal and interchangeable
- By a unique identity (Id)
- By memory reference only
- Value objects cannot be compared
Answer: By its values — two with the same values are equal and interchangeable. A value object has no identity; it's defined entirely by its values, so 10 GBP equals 10 GBP.
Which C# feature is ideal for a value object because it is immutable and gives value equality for free?
- class
- struct with public fields
- interface
- record
Answer: record. A C# record is immutable by default and provides value equality, so == compares contents — perfect for value objects like Money.
How is an entity compared, and what distinguishes it from a value object?
- By its attributes; it has no identity
- By its identity (an Id); its data can change over time but its identity never does
- By memory address; entities are immutable
- Entities and value objects are compared identically
Answer: By its identity (an Id); its data can change over time but its identity never does. An entity has identity (an Id) and is compared by it — two customers named 'Sam Lee' are different people. Its attributes may change; its identity does not.
What is an invariant in DDD?
- A rule that must always hold true, enforced by the entity's methods
- A variable that never changes name
- A type that cannot be instantiated
- A database constraint only
Answer: A rule that must always hold true, enforced by the entity's methods. An invariant is a rule that must always be true (e.g. loyalty points can't go negative). Entities enforce invariants inside their methods.
What is an aggregate root?
- The database table that stores aggregates
- A value object inside the aggregate
- The single entity that is the only entry point into an aggregate and guards its invariants
- The repository class
Answer: The single entity that is the only entry point into an aggregate and guards its invariants. The aggregate root is the only way into the cluster; outside code never touches the children directly, so the root can guarantee its invariants.
Why is an aggregate's child collection kept private and exposed only as IReadOnlyList?
- To save memory
- So outsiders cannot add or change children behind the root's back and bypass its rules
- Because IReadOnlyList is faster
- It is a C# compiler requirement
Answer: So outsiders cannot add or change children behind the root's back and bypass its rules. Keeping the list private and exposing a read-only view stops callers sneaking a change past the root's invariant checks (e.g. order.Lines.Add).
How should one aggregate reference another aggregate?
- By holding the whole object as a field
- Through a static global variable
- It must never reference another aggregate
- By Id, not by object reference
Answer: By Id, not by object reference. Aggregates reference each other by Id to keep consistency boundaries clear and transactions small — e.g. Order holds a CustomerId, not a Customer object.
What is the rule for the number of repositories per aggregate?
- One repository per child entity
- One repository per aggregate root, loading and saving the whole aggregate as a unit
- One global repository for everything
- No repositories are used in DDD
Answer: One repository per aggregate root, loading and saving the whole aggregate as a unit. There's one repository per aggregate root; it always loads and saves the aggregate as a unit, hiding the database from the domain.
When should a domain event be dispatched?
- Before the change is saved, so handlers run first
- At application startup
- Only after the change is committed, so a failed save doesn't fire side effects for nothing
- Domain events are never dispatched
Answer: Only after the change is committed, so a failed save doesn't fire side effects for nothing. Dispatch domain events only after the change is saved; otherwise a failed save would have already fired emails for an order that doesn't exist.
Continue this course
- Previous: Clean Architecture in .NET (Full Deep Dive)
- Next: Microservices in .NET (gRPC, Messaging, Resilience) — Build .NET microservices with gRPC, MassTransit, Polly, and Docker
- Quick reference: C# cheat sheet › Collections