Concepts
What you will learn: Choose code boundaries so changing a discount rule or payment provider does not force edits in unrelated parts. We start with a class’s responsibility and an interface contract, then connect the principles to application structure and its costs.
Before you start: A class groups data and behavior, a method performs an operation, and an interface is a contract listing operations a caller may request. A dependency is something a class needs in order to work. If these terms are new, follow the small examples before memorizing principle names.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#S: single responsibility and cohesion
If changing an email layout requires touching invoice calculations, responsibilities are mixed. SRP groups code that changes for the same reason and separates code that changes for different reasons; it does not require one method per class. Cohesion measures how closely a module's responsibilities belong together; coupling concerns its dependencies on other modules. Aim for cohesive responsibilities and few explicit dependencies.
public sealed class InvoiceService
{
public string BuildEmail(decimal subtotal) =>
$"<p>Total: {subtotal * 1.14m}</p>";
}public sealed class InvoiceTotals(decimal taxRate)
{
public decimal Calculate(decimal subtotal) => subtotal * (1 + taxRate); // (1)
}
public sealed class InvoiceEmail
{
public string Format(decimal total) => $"Total: {total:0.00}"; // (2)
}- (1) Tax policy has a clear home; the rate is an input, not a universal tax rule.
- (2) Message formatting can change without changing the calculation.
Rules can now be tested without an email server. Do not split every line into a class: responsibilities that always change together may become harder to follow when fragmented.
#O: open/closed at a real variation point
If every discount type adds another branch to the same large switch, you have a recurring variation point. OCP lets new variants implement a stable contract. It does not prohibit editing existing code: bugs and contract changes still require changes and tests.
decimal Discount(decimal total, string kind) => kind switch
{
"percent" => total * 0.1m,
"fixed" => Math.Min(total, 50m),
_ => throw new ArgumentException("Unknown discount")
};public interface IDiscountRule { decimal Calculate(decimal total); } // (1)
public sealed class PercentDiscount(decimal rate) : IDiscountRule
{
public decimal Calculate(decimal total) => total * rate;
}
public sealed class DiscountCalculator(IDiscountRule rule)
{
public decimal Calculate(decimal total) => rule.Calculate(total); // (2)
}- (1) A production contract must define valid totals, rates and rounding.
- (2) The composition root or a factory chooses the rule; the calculator need not know every variant.
Strategy and are implementation tools. A small switch over a stable closed set may be clearer than many interfaces. Avoid extension points for speculative requirements.
#L: Liskov and rectangle/square
Inheritance is a behavioral promise: a subtype must work wherever its base is expected without breaking the client's contract. It must not strengthen preconditions or weaken promised postconditions. A square is mathematically a rectangle, but not a mutable rectangle whose width and height can change independently.
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
}
public sealed class Square : Rectangle
{
public override int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; }
}
public override int Height
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}
// For a Rectangle, the client expects 5 * 10 = 50.
Rectangle shape = new Square();
shape.Width = 5;
shape.Height = 10; // Width also becomes 10; area = 100public interface IShape { int Area { get; } } // (1)
public sealed record Rectangle(int Width, int Height) : IShape
{
public int Area => Width * Height;
}
public sealed record Square(int Side) : IShape
{
public int Area => Side * Side; // (2)
}- (1) The new contract promises no independent dimension setters.
- (2) Each shape calculates area from an appropriate representation. Positive dimensions and overflow checks are additional requirements depending on use.
These are independent alternatives, not definitions to combine in one file. An exception alone does not prove an LSP violation; ask whether it violates the advertised contract.
#I: interface segregation through capabilities
An interface is a small contract describing what a caller may ask an object to do. If the payment contract promises every provider can refund, it makes a false promise for a cash provider that cannot reverse an electronic . ISP splits contracts around the capability a caller actually needs, not an arbitrary method count.
public interface IPaymentProvider
{
Task ChargeAsync(decimal amount);
Task RefundAsync(string paymentId);
}
public sealed class CashProvider : IPaymentProvider
{
public Task ChargeAsync(decimal amount) => Task.CompletedTask;
public Task RefundAsync(string paymentId) => throw new NotSupportedException();
}public interface IChargeProvider
{
Task ChargeAsync(decimal amount, CancellationToken ct);
}
public interface IRefundProvider
{
Task RefundAsync(string paymentId, CancellationToken ct); // (1)
}
public sealed class RefundPayment(IRefundProvider refunds)
{
public Task Run(string paymentId, CancellationToken ct) =>
refunds.RefundAsync(paymentId, ct); // (2)
}- (1) Only refund-capable providers implement this promise.
- (2) The use case requests the capability directly instead of scattering type checks.
The bad example is a behavioral sketch, not a real payment implementation. One provider can implement both contracts without becoming two objects. Splitting contracts that always change together and serve the same clients can add noise.
#D: dependency inversion is more than injection
- OrderService needs to take payment, but order policy should not need a provider's name or details.
- Define an IPaymentGateway contract around the operation order policy needs. A provider adapter implements it, and the order service receives that implementation.
- concerns the direction of dependency on the contract; supplies an implementation. Injecting a concrete infrastructure class still uses DI, so DI alone does not establish DIP.
In order confirmation, the policy is the application rule: what should happen after confirmation? Infrastructure is how the work is carried out, such as writing a file or calling an external . If checkout chooses that mechanism itself, changing storage also changes business code. An abstraction here is a small contract describing checkout’s need without choosing the mechanism. DIP makes the business rule depend on that contract, which the mechanism implements. Dependency injection (DI) is how the implementation is supplied from outside; directly injecting a concrete class alone is not DIP.
public sealed class ConfirmOrder
{
public void Run(int id) =>
File.AppendAllText("orders.log", $"Confirmed {id}\n");
}// Application project
public interface IOrderNotifications { void Confirmed(int orderId); } // (1)
public sealed class ConfirmOrder(IOrderNotifications notifications)
{
public void Run(int id) => notifications.Confirmed(id);
}
// Infrastructure project references Application.
public sealed class FileOrderNotifications(string path) : IOrderNotifications
{
public void Confirmed(int id) =>
File.AppendAllText(path, $"Confirmed {id}\n"); // (2)
}
// Composition root supplies path and implementation. // (3)- (1) Application describes a need without File .
- (2) An adapter implements the detail; production design must define reliability and concurrency requirements.
- (3) Startup selects the implementation, outside business logic.
This illustrates dependency direction, not durable notification delivery. Do not create an interface for every pure utility; isolate boundaries with real reasons to vary or be substituted.
#Layered, Clean, Hexagonal, Onion and vertical slices
Does a feature require visiting ten folders, or are domain rules tied to ? Choose organization to solve the actual problem. These styles overlap; they are not mutually exclusive frameworks.
| Style | Idea | Cost |
|---|---|---|
| Layered | Presentation, business and data roles | Pass-through layers; business may depend on data details |
| Clean | Source dependencies point inward toward domain and use cases | Ports, mapping and additional files |
| Hexagonal | Application core with inbound/outbound ports and adapters | Boundary contracts need careful design |
| Onion | Domain core surrounded by inward-dependent rings | Similar risk of excessive abstraction |
| Vertical slices | Organize endpoint, handler and data access by use case | Duplication if shared rules lack a domain home |
Clean source references:
Shop.Application → Shop.Domain (1)
Shop.Infrastructure → Shop.Application, Shop.Domain
Shop.Api → Shop.Application, Shop.Infrastructure (2)
Inside Application, organized by feature:
Features/Orders/PlaceOrder/Handler.cs (3)
Features/Orders/GetSummary/Handler.cs
- (1) Domain need not know or ASP.NET.
- (2) The host references infrastructure for wiring; runtime calls can reach adapters against the source-reference direction through interfaces.
- (3) Slices can live inside Clean boundaries.
Changing databases is not necessarily a one-file change: and semantics can leak. The main benefit is clear dependencies. Small applications may need only simple slices. Microsoft architecture guide.
#Strategic DDD: boundaries of meaning
Catalog treats Product as descriptions and images; Inventory treats it as quantities and storage locations. Forcing both into one giant model creates coupling. A defines a model's meaning, and a ubiquitous language aligns code with domain experts' vocabulary.
A context map records relationships: upstream and downstream roles, contract ownership, published language, and anti-corruption layers used for translation. A context is not necessarily a microservice, table or exact team boundary.
Catalog [upstream] -- ProductSnapshot v1 --> Sales [downstream] (1)
Payment provider -- external status --> ACL --> Sales.PaymentState (2)
Inventory owns availability; Catalog owns descriptions. (3)
- (1) Sales receives an explicit snapshot rather than a Catalog internal entity reference.
- (2) The translates external concepts and statuses, not merely field names.
- (3) Semantic ownership defines decision authority, not just folder location.
Start with conversations and concrete examples from domain experts. is not a class-naming exercise; a simple stable domain may not justify many tactical patterns.
#Tactical DDD: protecting invariants
If every controller decides independently whether paid orders can change, the rule will drift. An entity has persistent identity; a value object is defined by its values. An is a consistency boundary whose root controls changes, not an object graph containing everything connected by a foreign key.
public sealed record OrderLine(Guid ProductId, int Quantity);
public sealed record OrderConfirmed(Guid OrderId);
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
private readonly List<OrderConfirmed> _events = [];
public Guid Id { get; } = Guid.CreateVersion7();
public bool IsConfirmed { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // (1)
public IReadOnlyList<OrderConfirmed> Events => _events.AsReadOnly();
public void AddLine(Guid productId, int quantity)
{
if (IsConfirmed) throw new InvalidOperationException("Order locked");
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
_lines.Add(new(productId, quantity)); // (2)
}
public void Confirm()
{
if (IsConfirmed) return;
if (_lines.Count == 0) throw new InvalidOperationException("Empty order");
IsConfirmed = true;
_events.Add(new(Id)); // (3)
}
}
- (1) A read-only wrapper and lines prevent casting the exposed interface back to a mutable list.
- (2) Changes pass through enforced by the root.
- (3) A domain event records a fact without making network calls. Reliable publication after persistence may require an .
| Building block | Use |
|---|---|
| Value object | Money with amount and currency; immutable value equality and suitable validation |
| Repository | Load and save aggregates through domain-oriented contracts, not necessarily one per table |
| Domain service | Business behavior with no natural entity owner, not merely / orchestration |
| Factory | Create complex aggregates in a valid initial state |
This is a small model; translate expected failures to meaningful application results and protect database concurrency. Encapsulation alone does not prevent concurrent updates. Avoid aggregates containing millions of children: choose boundaries from invariants.
#Modular monolith: boundaries without a network
When teams need feature ownership without independent deployment, a modular monolith is a practical option: one deployable, modules with explicit contracts and hidden implementations. Separate folders do not create modularity if everyone directly queries everyone else's tables.
Shop.Catalog.Contracts ICatalogPrices, ProductPriceDto (1)
Shop.Catalog internal entities and persistence
Shop.Sales.Contracts PlaceOrderRequest, OrderSummary
Shop.Sales references Catalog.Contracts only (2)
Shop.Host registers module implementations (3)
- (1) Keep contracts small and independent of entities.
- (2) Modules communicate in-process through contracts or events, with compiler and architecture-test enforcement.
- (3) The host composes modules; one runtime does not imply singleton lifetimes for every service.
A shared database can use schemas with clear ownership. Cross-module deliberately increase coupling and are not automatic across separate DbContexts. Deployment and scaling usually apply to the whole application. Good boundaries help later extraction, but data migration and network failure handling remain real work.
#Microservices: independence has a cost
A separate service may pay off when independent scaling, release cadence or ownership is needed. A network call is not a method call: , partial failures, timeouts and versioning are design concerns.
| Benefit | Requirement and cost |
|---|---|
| Independent deployment and scaling | Backward-compatible contracts, separate pipelines and monitoring |
| Fault | Timeouts and bulkheads; synchronous call chains can propagate failures |
| Database per service | Data ownership; no bypass through direct cross-service database joins |
| Team autonomy | Meaningful boundaries and agreed event schemas |
An gateway provides routing and shared policies; a BFF serves a particular client's needs, such as mobile. Neither should become the home of all domain logic. Service discovery resolves logical names to instances through , a registry or platform routing.
Mobile → Mobile BFF → Sales API → Sales-owned database (1)
→ outbox → broker → Inventory (2)
Old application → routing boundary → extracted Catalog API (3)
- (1) Use synchronous or for immediate responses, with deadlines and failure handling.
- (2) Asynchronous messages decouple time but require , and lag monitoring. Database per service is ownership, not necessarily separate hardware.
- (3) Strangler fig moves features gradually behind explicit routing instead of a wholesale rewrite; avoid uncoordinated dual writes.
Not every needs a service, and asynchronous communication is not limited to . Start with the least deployment complexity meeting requirements and examine boundaries before splitting.
#CQRS: separate read and write needs
- Placing an order requires stock validation and pricing. An order list needs only IDs and statuses; write rules differ from read shapes.
- A command requests a change, such as PlaceOrder; a query asks for data, such as GetOrderSummary. Separate paths can serve these needs clearly.
- Start inside one application if sufficient, possibly sharing one database. A separate read store and update lag are additional decisions, not automatic requirements.
Confirming an order needs ; displaying a summary needs a small . CQRS separates command and query models and paths. Here “light” means one database and “full” is shorthand for a separate read store; these are common labels, not formal maturity levels. is optional.
// Application fragments; db exposes EF-mapped Orders.
public sealed record ConfirmOrder(int Id);
public sealed record OrderSummary(int Id, string Status);
// Command handler: load, enforce domain behavior, persist.
var order = await db.Orders.SingleAsync(o => o.Id == command.Id, ct);
order.Confirm();
await db.SaveChangesAsync(ct); // (1)
// Query handler: project directly, without loading the aggregate.
var summary = await db.Orders.AsNoTracking()
.Where(o => o.Id == id)
.Select(o => new OrderSummary(o.Id, o.Status))
.SingleOrDefaultAsync(ct); // (2)
- (1) Commands use domain behavior. This fragment assumes a separate model with integer Id and string Status, independent of the earlier example.
- (2) Queries return . This projection contains no entities to track; AsNoTracking documents intent.
| Choice | Benefit | Cost |
|---|---|---|
| Same database | Separate write rules from presentation shape | Additional handlers and DTOs |
| Separate read store | Independent read shaping and scaling | Lag, rebuilds, event versioning and monitoring |
| Event sourcing | Events are authoritative; see advanced topics | Replay, evolution and historical corrections |
MediatR is an implementation choice, not CQRS itself; check licensing before adoption. Purely ceremonial handler separation may add little to a small application.
#ADRs and architecture tests
Boundaries drift when their rationale is forgotten and nothing checks them. An records one decision: context, alternatives, decision, consequences and status. Record costs and revisit triggers instead of describing every choice as ideal.
ADR 004: Keep checkout in the modular monolith (1)
Status: Accepted
Context: Stock reservation and order creation need one local commit.
Alternatives: Shared transaction; service split with saga.
Decision: Shared transaction with explicit module participation.
Consequence: More coupling; simpler atomic persistence.
Revisit: Inventory requires independent ownership or deployment.
// Test project: NetArchTest.Rules and the chosen xUnit package.
using NetArchTest.Rules;
using Xunit;
public sealed class BoundaryTests
{
[Fact]
public void Domain_does_not_depend_on_infrastructure()
{
var assembly = typeof(Shop.Domain.Order).Assembly; // (2)
var result = Types.InAssembly(assembly)
.ShouldNot().HaveDependencyOn("Shop.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful); // (3)
}
}
- (1) Scope the decision to its context; supersede it with another ADR rather than erasing history.
- (2) Select the correct assembly; checking an empty assembly can give false confidence.
- (3) fails on a forbidden dependency. Inspect failing types and try an intentional violation when establishing the rule.
NetArchTest and ArchUnitNET express dependency rules; they do not prove domain correctness or stop raw from bypassing ownership. If Catalog.Contracts is allowed, avoid a forbidden namespace prefix that accidentally includes it; separate implementation namespaces or define precise groups.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
14.1How do you explain SOLID without just expanding the acronym?Junior
I explain as managing change and contracts. Single responsibility keeps unrelated reasons to change separate. Open/closed supports new variants through stable extension points. Liskov requires implementations to honor the behavior clients expect. Interface segregation gives clients focused capabilities. Dependency inversion keeps business policy independent of infrastructure details through suitable abstractions. I apply them where they reduce real coupling; I would not add interfaces and layers everywhere merely to make the code look compliant.
14.2Does single responsibility mean one method per class?Junior
No. I look for reasons to change and the people or policies driving those changes. An invoice calculator can have several methods that implement one cohesive pricing policy. Mixing that policy with email formatting and file storage introduces unrelated reasons to edit it. Splitting every method into its own class would increase and obscure the workflow. I aim for cohesive modules whose responsibilities can be explained clearly, rather than treating method count as a design rule.
14.3What are coupling and cohesion?Junior
Cohesion describes how strongly a module's responsibilities belong together. Coupling describes how much it depends on other modules and their details. I prefer high cohesion and dependencies that are few, explicit and stable. An interface can reduce implementation coupling, but it cannot remove semantic coupling if callers must know the provider's internal workflow. Shared tables and coordinated deployment can also create coupling even when the source code has very few direct references.
14.4What is an entity compared with a value object?Junior
An entity is identified by an identity that remains meaningful while its attributes change, such as an order number. A value object is defined by its values, such as an amount and currency, and is normally . I use it to express and validate a domain concept rather than passing loosely related primitives. A C# record helps with value equality, but I still need to design validation and immutability; choosing the record keyword alone does not complete the model.
14.5How does the rectangle/square example violate Liskov substitution?Mid
The problem is the mutable contract, not the mathematical relationship. A rectangle client can set width to five and height to ten and expect an area of fifty. If Square overrides both setters to keep the sides equal, the same sequence produces one hundred. The subtype has broken the caller's expectation. I would model both behind an area-producing shape abstraction or use shapes, rather than forcing Square into an independent-dimension mutation contract.
14.6How does interface segregation help payment providers?Mid
I separate capabilities clients actually require, such as charging and refunding. A refund use case depends on IRefundProvider, while a charge-only provider never promises that capability. A provider supporting both can implement both interfaces. This avoids empty methods or unsupported-operation surprises and often helps satisfy Liskov as well. I would not split every method automatically; I look at which clients use the operations and whether those capabilities really vary independently.
14.7What is dependency inversion versus dependency injection?Mid
Dependency inversion is about source dependency direction: high-level policy depends on abstractions, and lower-level details implement those abstractions. Dependency injection is a construction technique that supplies collaborators from outside, usually through constructors. I can inject a concrete and still couple policy to infrastructure, so injection alone is not inversion. I define the contract around what the use case needs and let the composition root connect it to an adapter.
14.8What distinguishes Clean, Hexagonal and Onion architecture?Mid
I see them as related approaches to protecting the application core. Clean Architecture emphasizes entities, use cases and inward source dependencies. Hexagonal Architecture emphasizes inbound and outbound ports with replaceable adapters. Onion Architecture places the domain at the center of dependency rings. Their vocabulary differs, but all can keep and persistence details outside core policy. I choose the boundaries needed by the application rather than adding every named layer from a diagram.
14.9Can vertical slices coexist with Clean Architecture?Mid
Yes. Vertical slices organize code by use case, while Clean Architecture primarily constrains dependency direction. I can place a command handler, request and related application logic together within a feature while keeping domain in a shared domain model and infrastructure behind ports. The trade-off is balancing local feature changes against duplication of shared rules. For simple , a slice may access directly if that is an intentional boundary choice rather than an accidental dependency leak.
14.10What are bounded contexts and ubiquitous language?Mid
A is a boundary within which a model and its terminology have a consistent meaning. Product can mean descriptions and images in Catalog but quantities and locations in Inventory. Ubiquitous language is the shared vocabulary used by domain experts and code inside that boundary. I document relationships in a context map, including ownership and translation. I do not assume every context must become a microservice; modeling boundaries and deployment boundaries are separate decisions.
14.11What is an aggregate, and what does its root protect?Mid
An is a cluster of domain objects treated as a consistency boundary, with a root controlling changes and enforcing . For an order, adding a line can check that the order is still editable and the quantity is valid. I do not include every related entity just because a foreign key connects it. Large aggregates increase loading and contention. I also enforce concurrency at persistence boundaries, because a well-encapsulated object alone cannot coordinate two application instances.
14.12How do domain services, application services and repositories differ?Mid
A domain service holds business behavior that has no natural owner in an entity or value object. An application service coordinates a use case, such as loading data, invoking domain behavior and committing changes. A repository provides domain-oriented persistence access to aggregates. I avoid putting all business logic in application services or repositories, because that leaves the model unable to protect its own rules. I also avoid inventing a domain service for every ordinary orchestration step.
14.13When would you choose a modular monolith over microservices?Senior
I choose a modular monolith when one deployment is acceptable and clear module ownership gives most of the needed separation. It keeps local calls and makes shared transactional work easier to coordinate. Microservices become attractive when independent deployment, scaling or team ownership provides a measurable benefit worth network and operational complexity. I enforce contracts and data ownership from the start, but I do not promise that extracting a module later is trivial; migration and distributed failure handling still require design.
14.14How do you keep a modular monolith from becoming a distributed-looking big ball of mud?Senior
I make module contracts explicit and keep implementation types internal. Modules own their data and do not query each other's tables as a shortcut. Architecture tests check dependencies, while reviews and integration tests cover boundaries static analysis cannot see. If a shared is necessary, I document its coupling and require explicit participation rather than hidden cross-module access. I also avoid forcing in-process modules to imitate network services when that adds and complexity without a requirement.
14.15How do gateway, BFF, discovery and communication style fit microservices?Senior
A gateway handles entry routing and shared policies; a BFF adapts backend interactions to one client's needs. Service discovery resolves logical service names to current instances. I use synchronous calls when an immediate response is required and asynchronous messages when temporal decoupling is useful. The latter still needs duplicate handling, ordering decisions and lag monitoring. I avoid long synchronous chains and keep domain rules in their owning services, rather than turning the gateway into a central business engine.
14.16Does CQRS require separate databases, MediatR or event sourcing?Senior
No. I can separate command behavior and query projections while using one database and injecting handlers directly. A separate read store is useful for independent read shaping or scaling, but introduces propagation lag, rebuilds and operational work. is another decision about making events the authoritative history rather than storing only current state. MediatR is one dispatch implementation. I introduce each choice for a concrete requirement instead of adopting a whole bundle because it is labeled .
14.17How would you migrate a monolith using the strangler fig pattern?Senior
I select a capability with clear ownership and introduce a routing boundary that can send its traffic to a new implementation while the old application continues serving the rest. I define authoritative data ownership and migrate reads and writes deliberately, with reconciliation and a rollback plan. Uncoordinated dual writes are a major risk. I also preserve contract compatibility and observe errors and during rollout. This reduces migration scope per step, but does not remove the need to untangle shared data.
14.18What should ADRs and architecture tests each contribute?Senior
An preserves why a decision was made, the alternatives considered, accepted costs and conditions for revisiting it. Architecture tests enforce machine-checkable boundaries, such as preventing domain dependencies on infrastructure. I use both because a test cannot explain the business reason and prose cannot stop dependency drift. I verify that the test selects real types and catches an intentional violation. Neither replaces behavioral tests, and static dependency rules cannot reliably detect every raw or runtime integration boundary violation.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
An invoice class changes for tax policy and email layout. Which principle is most directly involved?
A Square changes Width when a client sets Height through Rectangle. What contract is at risk?
What best demonstrates interface segregation?
Which statement about DI and DIP is accurate?
Can feature slices exist inside Clean Architecture boundaries?
Which DDD concept defines where Product has one consistent meaning?
What is an aggregate root primarily responsible for?
Which benefit most directly motivates a microservice split?
What is required by CQRS itself?
What should an architecture decision record preserve?