This episode raises the level from code to architecture: clean architecture, layered architecture, and the modular monolith, mediator and event-driven design, CQRS and domain-driven design, and service composition and bounded contexts for enterprise applications.

Correct code does not automatically become a maintainable system. Episode 16 covers architecture: how to structure projects so they stay easy to change, test, and scale as teams and features grow. You will learn about clean architecture, layered architecture, the modular monolith, CQRS, and domain-driven design.
These patterns are not rigid rules but thinking tools. The key is separating what changes often (framework, UI, database) from the stable domain core — and keeping the direction of dependencies acyclic.
Layered architecture divides the application into Presentation, Application, Domain, and Infrastructure. Clean architecture enforces the dependency rule: inner layers (Domain) must not depend on outer layers. Dependency direction points inward.
A common project structure:
dotnet new sln -n Order
dotnet new classlib -n Order.Domain -o src/Order.Domain
dotnet new classlib -n Order.Application -o src/Order.Application
dotnet new webapi -n Order.Api -o src/Order.Api
dotnet sln add src/Order.Domain src/Order.Application src/Order.ApiOrder.Domain holds pure entities and business rules. Order.Application holds use cases and interfaces. Order.Api connects both to the outside world. References are one-directional: Application to Domain, Api to Application.
The advantages are clear: the domain can be tested without a database or web server, and replacing the UI or database does not touch business logic. The cost is higher initial complexity — don't apply full clean architecture to a small application.
The modular monolith combines the simplicity of a monolith with microservice separation: one deployed application, split into self-contained modules with firm boundaries.
internal class OrderModule
{
public static void Register(IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
}
}Each module registers its own services. Modules communicate through internal interfaces, not by directly calling another module's implementations. This preserves autonomy without the operational cost of microservices — a good strategy to start with and split up later.
The mediator separates the caller from the handler — sending a command to the matching handler. The MediatR library is the most popular implementation:
dotnet add package MediatRThen define a command and handler:
public record BuatPesananCommand(string Produk, int Qty) : IRequest<int>;
public class BuatPesananHandler : IRequestHandler<BuatPesananCommand, int>
{
public Task<int> Handle(BuatPesananCommand request, CancellationToken ct)
{
Console.WriteLine($"{request.Produk} x {request.Qty}");
return Task.FromResult(1);
}
}IMediator.Send(new BuatPesananCommand(...)) forwards the command to the registered handler. The sending code does not know which handler handles it — dependencies become loose and easy to replace or test.
Besides commands, MediatR supports notifications for events. When one event happens, many handlers run — for example, when an order is created, send an email and update stock. Dispatching is done with IMediator.Publish(event). This separates side effects from the core logic.
CQRS (Command Query Responsibility Segregation) separates the read and write models. Commands change state; queries read without side effects. With separate command/query handlers, each can be scaled and optimized independently.
Domain-Driven Design (DDD) focuses on modeling complex domains: entities with identity, immutable value objects, aggregates that maintain consistency, and repositories that wrap persistence. DDD is most useful when business rules are complex — not for simple CRUD.
A bounded context divides the domain into separate subdomains — for example Order and Billing — each with its own model and vocabulary. Service composition happens through interfaces between contexts, not by sharing internal classes.
public interface IBillingClient
{
Task TagihAsync(int pesananId, decimal total);
}
public class BillingClient : IBillingClient
{
private readonly HttpClient _http;
public BillingClient(HttpClient http) => _http = http;
}IBillingClient is the cross-context contract exposed by the Billing module and consumed by Order. When split into microservices later, this contract becomes an API — by swapping the BillingClient implementation from local to HTTP without changing its consumers.
Info
Start simple: a tidy layered architecture is better than a half-finished clean architecture. Add patterns like CQRS and DDD when the domain complexity truly demands them.
Key takeaways:
In the next episode 17 we will discuss messaging and integration — background and hosted services, the RabbitMQ, Kafka, and Azure Service Bus message brokers, gRPC and REST integration, and distributed transactions and eventual consistency.