30 - Architecture
There are many-many books written about these topics. But some principles and patterns are commonly used in software architecture.
Principles
YAGNI (You Ain’t Gonna Need It)
YAGNI advises against implementing features until they are necessary, to avoid unnecessary complexity and maintain focus on immediate requirements.
KISS (Keep It Simple, Stupid)
KISS encourages simplicity in design and development, favoring straightforward solutions over complex ones to improve understandability and maintainability.
DRY (Don’t Repeat Yourself)
DRY promotes code reusability by avoiding duplication of code or logic, leading to cleaner, more maintainable codebases.
SOLID
-
Single Responsibility Principle (SRP)
"Each class should have only one reason to change."Bad — controller does everything:
public class PersonController : ControllerBase
{
private readonly AppDbContext _ctx;
[HttpPost]
public async Task<IActionResult> Create(PersonCreateRequest req)
{
// validation
if (string.IsNullOrEmpty(req.Name)) return BadRequest("Name required");
if (req.Name.Length < 2) return BadRequest("Name too short");
// mapping
var person = new Person { Name = req.Name, PasswordHash = BCrypt.HashPassword(req.Password) };
// data access
_ctx.Persons.Add(person);
await _ctx.SaveChangesAsync();
// notification
await SendWelcomeEmail(person.Email);
return Ok(person); // leaks entity with PasswordHash
}
}
Fixed — each class has one job:
public class PersonController : ControllerBase
{
private readonly IPersonService _service;
[HttpPost]
public async Task<ActionResult<PersonResponse>> Create(PersonCreateRequest req)
{
var result = await _service.CreateAsync(req); // that's it
return Ok(result);
}
}
// Validation -> PersonValidator
// Mapping -> PersonMapper
// Data access -> PersonRepository
// Notification -> IEmailService
// Orchestration -> PersonService
-
Open/Closed Principle (OCP)
"Software entities should be open for extension but closed for modification."
Add new behavior without touching existing code.Bad — adding a new discount type means editing the method:
public decimal CalculateDiscount(Order order)
{
if (order.CustomerType == "Premium") return order.Total * 0.2m;
if (order.CustomerType == "Employee") return order.Total * 0.3m;
// add new type here -> modify existing code every time
return 0;
}
Fixed — new behavior = new class:
public interface IDiscountStrategy
{
bool AppliesTo(string customerType);
decimal Calculate(Order order);
}
public class PremiumDiscount : IDiscountStrategy
{
public bool AppliesTo(string type) => type == "Premium";
public decimal Calculate(Order order) => order.Total * 0.2m;
}
// Adding a new discount = add a new class. Existing code untouched.
public class DiscountService
{
private readonly IEnumerable<IDiscountStrategy> _strategies;
public decimal CalculateDiscount(Order order) =>
_strategies.FirstOrDefault(s => s.AppliesTo(order.CustomerType))
?.Calculate(order) ?? 0;
}
-
Liskov Substitution Principle (LSP)
"Subtypes should be substitutable for their base types without altering the correctness of the program."
If code works with the base type, every derived type must honor that contract.Bad — subtype breaks the base type's contract:
public class Bird
{
public virtual void Fly() => Console.WriteLine("Flying");
}
public class Penguin : Bird
{
public override void Fly() => throw new NotSupportedException("Can't fly!");
// anything expecting Bird.Fly() to work just exploded
}
Fixed — model what's actually common:
public abstract class Bird
{
public abstract void Move();
}
public class Sparrow : Bird
{
public override void Move() => Console.WriteLine("Flying");
}
public class Penguin : Bird
{
public override void Move() => Console.WriteLine("Swimming");
}
// any code calling bird.Move() works correctly for both
-
Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they don't use."
Break fat interfaces into focused ones.Bad — one fat interface forces irrelevant implementations:
public interface IWorker
{
void Work();
void Eat();
void Sleep();
void AttendMeeting();
}
public class Robot : IWorker
{
public void Work() { /* ok */ }
public void Eat() => throw new NotSupportedException(); // robots don't eat
public void Sleep() => throw new NotSupportedException(); // or sleep
public void AttendMeeting() => throw new NotSupportedException();
}
Fixed — thin, role-specific interfaces:
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface IHuman : IWorkable, IFeedable { void AttendMeeting(); }
public class Robot : IWorkable
{
public void Work() { /* only what it actually does */ }
}
-
Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules; both should depend on abstractions."
This is the principle that directly enables DI and Clean Architecture.Bad — high-level service depends on low-level implementation:
public class OrderService
{
private readonly SqlOrderRepository _repo = new(); // concrete
private readonly SmtpEmailSender _emailSender = new(); // concrete
// locked to SQL Server and SMTP forever. Can't test. Can't swap.
}
Fixed — both depend on abstractions:
public class OrderService
{
private readonly IOrderRepository _repo; // abstraction
private readonly IEmailSender _emailSender; // abstraction
public OrderService(IOrderRepository repo, IEmailSender emailSender)
{
_repo = repo;
_emailSender = emailSender;
}
}
// High-level (OrderService) and low-level (SqlOrderRepository, SmtpEmailSender)
// both depend on the interface. Neither knows about the other.
// This is exactly what Clean/Onion Architecture exploits at the project level.
Note: DIP is the principle, DI (Dependency Injection) is the mechanism that implements it, and the IoC Container is the tool that automates it.
Implementation Patterns
Repository
An abstraction layer between your business logic and data access. Instead of scattering DbContext calls everywhere, you funnel data operations through a defined interface. The point: swappable implementations, testability, and a single place to look for data access logic.
Interface:
public interface IPersonRepository
{
Task<IEnumerable<Person>> GetAllAsync();
Task<Person?> GetByIdAsync(int id);
Task<Person> AddAsync(Person person);
Task UpdateAsync(Person person);
Task DeleteAsync(int id);
}
Implementation:
public class PersonRepository : IPersonRepository
{
private readonly AppDbContext _ctx;
public PersonRepository(AppDbContext ctx) => _ctx = ctx;
public async Task<IEnumerable<Person>> GetAllAsync() =>
await _ctx.Persons.ToListAsync();
public async Task<Person?> GetByIdAsync(int id) =>
await _ctx.Persons.FindAsync(id);
public async Task<Person> AddAsync(Person person)
{
_ctx.Persons.Add(person);
// no SaveChanges — that's UoW's job
return person;
}
public async Task UpdateAsync(Person person)
{
_ctx.Persons.Update(person);
}
public async Task DeleteAsync(int id)
{
var person = await _ctx.Persons.FindAsync(id);
if (person != null)
{
_ctx.Persons.Remove(person);
// no SaveChanges — that's UoW's job
}
}
}
DI Registration:
builder.Services.AddScoped<IPersonRepository, PersonRepository>();
EF's DbContext is already a Repository + Unit of Work. DbSet<T> is a repository, SaveChanges is UoW. So wrapping it in another repository is debatable.
The argument for doing it: your business layer depends on IPersonRepository, not on EF. Swap to Dapper, raw SQL, or an API call — business logic doesn't care.
The argument against: you're hiding EF's power (LINQ composition, change tracking, navigation properties) behind a lobotomized CRUD interface, and nobody actually swaps ORMs.
Unit of Work
Tracks all changes you make during a business transaction and commits them in one atomic operation. Either everything saves, or nothing does.
public interface IUnitOfWork : IDisposable
{
IPersonRepository Persons { get; }
IAddressRepository Addresses { get; }
Task<int> SaveChangesAsync();
}
DbContext already is a Unit of Work. It tracks all entity changes and SaveChangesAsync() commits them in a single transaction. The explicit UoW wrapper gives you a clean seam for the interface boundary and keeps repositories from going rogue with independent saves.
DTO (Data Transfer Objects)
Dumb objects with no logic — just properties. Their job: carry data between layers without leaking internal structure.
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public string PasswordHash { get; set; } // oops, now your API returns this
public decimal Salary { get; set; } // and this
public ICollection<Address> Addresses { get; set; } // circular ref, serializer explodes
}
// What the API returns
public class PersonResponse
{
public int Id { get; set; }
public string Name { get; set; }
public int AddressCount { get; set; }
}
// What the API accepts for creation
public class PersonCreateRequest
{
public string Name { get; set; }
public string Password { get; set; }
}
// What BLL passes around internally
public class PersonBllDto
{
public int Id { get; set; }
public string Name { get; set; }
public string PasswordHash { get; set; }
}
Mapper
Something has to convert between DTO's and layers.
Manual - lots of code, trivial, but explicit and easy to debug.
public static PersonResponse ToResponse(Person entity)
{
return new PersonResponse
{
Id = entity.Id,
Name = entity.Name,
AddressCount = entity.Addresses?.Count ?? 0
};
}
AutoMapper - less code, but magic. Switched to non-OSS license in v13; Mapster (MIT) is gaining traction as alternative.
public class PersonProfile : Profile
{
public PersonProfile()
{
CreateMap<Person, PersonResponse>()
.ForMember(d => d.AddressCount,
opt => opt.MapFrom(s => s.Addresses.Count));
CreateMap<PersonCreateRequest, Person>();
}
}
Controllers should never see domain entities. DAL returns entities to BLL, BLL maps and returns DTOs to Web layer. The mapping is the boundary enforcement.
Dependency Injection
Don't create your dependencies — demand them through the constructor. Let someone else figure out what concrete implementation to hand you.
Problem:
public class PersonService
{
private readonly PersonRepository _repo;
public PersonService()
{
// hardwired to this exact implementation
// good luck testing this
// good luck swapping to a different repo
_repo = new PersonRepository(new AppDbContext(???));
}
}
You've welded your service to a concrete class. Want to unit test? You need a real database. Want to swap implementations? Rewrite the class.
Depend on the abstraction:
public class PersonService : IPersonService
{
private readonly IPersonRepository _repo;
public PersonService(IPersonRepository repo)
{
_repo = repo; // don't know, don't care what the implementation is
}
public async Task<PersonResponse> GetPersonAsync(int id)
{
var person = await _repo.GetByIdAsync(id);
return person.ToResponse();
}
}
The container wires it all up:
// Program.cs
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connStr));
builder.Services.AddScoped<IPersonRepository, PersonRepository>();
builder.Services.AddScoped<IPersonService, PersonService>();
builder.Services.AddTransient<IEmailSender, EmailSender>(); // new instance every time
builder.Services.AddScoped<IPersonService, PersonService>(); // one instance per HTTP request
builder.Services.AddSingleton<ICacheService, CacheService>(); // one instance for app lifetime
The rule: a service can only depend on services with equal or longer lifetime.
Singleton -> can inject: Singleton only
Scoped -> can inject: Scoped, Singleton
Transient -> can inject: Transient, Scoped, Singleton
What to use when
| Lifetime | Use for | Example |
|---|---|---|
| Scoped | Anything touching DbContext or per-request state | Repositories, Services, UoW |
| Transient | Lightweight, stateless, cheap to create | Validators, mappers, formatters |
| Singleton | Expensive to create, thread-safe, shared state | Cache, HttpClient factory, config |
90% of the time it's AddScoped.
Violate this and you get a captive dependency — a Singleton holding a Scoped DbContext that's reused across requests.
ASP.NET Core throws at startup in Development (ValidateScopes = true), silently breaks in Production.
Testing is easy now
[Fact]
public async Task GetPerson_ReturnsMapped()
{
var mockRepo = new Mock<IPersonRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(new Person { Id = 1, Name = "Test" });
var service = new PersonService(mockRepo.Object);
var result = await service.GetPersonAsync(1);
Assert.Equal("Test", result.Name);
}
Architectural patterns
MVC Model-View-Controller
Controller should be thin. It does three things: receive input, call a service, pick a view. If your controller has business logic, LINQ queries, or mapping code — it's doing too much (SRP violation, see SOLID above).
Views should be dumb. No business logic in .cshtml. No if (user.Role == "Admin" && user.Subscription.Tier > 2) in Razor. Compute that in the service, put a bool ShowAdminPanel on the ViewModel.
ViewModels are NOT entities. This is the DTO concept applied to the View layer. The View gets exactly what it needs to render — nothing more. Entity goes in, ViewModel comes out, the controller/service does the mapping.
Browser -> Controller -> Service -> Repository -> DB
↓
Browser <- View <- ViewModel <- Service <- Entity <-
N-Tier
Split your application into horizontal layers, each with a single responsibility. Each layer only talks to the one directly below it.
What lives where
| Layer | Contains | Depends on |
|---|---|---|
| Web | Controllers, DTOs (request/response), middleware, views/Razor, DI setup | BLL |
| BLL | Service interfaces + implementations, business DTOs, mapping, validation | DAL |
| DAL | DbContext, entities, repository interfaces + implementations, migrations | nothing |
Project references go one direction only:
Web -> BLL -> DAL
Web references BLL. BLL references DAL. Web does NOT reference DAL directly. If your controller is touching DbContext, something is wrong.
Dependencies flow downward: Web -> BLL -> DAL. This means DAL is the most depended-on project. Change an entity, and ripple goes up through everything. This is the fundamental weakness of N-tier — your most volatile layer (data model) sits at the bottom where everything depends on it.
This is exactly what Clean/Onion Architecture inverts — domain goes to the center, infrastructure (EF, DB) moves to the outside. But that's the next lecture. Students need to build N-tier first and feel the coupling pain before the inversion makes any sense.
Clean / Onion Architecture
Same layers as N-tier, one critical change: flip the dependency direction. Instead of everything depending on DAL, everything depends on a shared abstractions project (or multiple). Infrastructure (EF, DB) becomes a plugin — it depends inward, not the other way around.
The transformation from N-tier
N-tier had this:
Web -> BLL -> DAL
DAL owned entities, interfaces, AND implementations. BLL depended on DAL. Changing your ORM meant rewriting everything.
The move: extract interfaces and entities out of DAL into their own projects.
Split DAL into "what things look like" (Domain) and "how they're actually done" (Infrastructure). The interfaces and entities moved inward, the implementations moved outward.
Project references — the critical difference
N-tier: Web -> BLL -> DAL (everything points down to DAL)
Clean/Onion: Infrastructure -> Domain <- Application <- Web
Domain references nothing. It's the center. Everything else depends on it. Infrastructure is a leaf — remove EF Core, swap to Dapper, nobody else notices.
N-tier vs Clean Architecture
The entire difference between N-tier and Clean Architecture is who owns the interfaces. That's it. Move IPersonRepository from DAL into Domain, and your dependency arrow flips. Infrastructure now depends on Domain instead of Domain depending on Infrastructure.
One refactoring move, and you've inverted the architecture. Everything else — the folder names, the onion rings diagram, the "ports and adapters" terminology — is just different ways of describing that same dependency flip.
Modular Monolith
One deployable, but internally split into self-contained modules. Each module owns its domain, data, and services. Modules communicate through well-defined interfaces — not by reaching into each other's guts.
Think of it as: Clean Architecture applied per feature/domain, all living in one process.
Why
| Approach | Problem |
|---|---|
| Classic monolith | Everything references everything. One change -> cascade everywhere |
| Microservices | Distributed systems hell. Network, serialization, eventual consistency. Overkill for most teams |
| Modular monolith | Clean boundaries like microservices, deployment simplicity of a monolith. Split later if you actually need to |
Structure
MyApp.sln
├── MyApp.Web // Composition root, routing, DI wiring
│
├── Modules/
│ ├── MyApp.Modules.Persons/
│ │ ├── Domain/ // Entities, interfaces
│ │ ├── Application/ // Services, DTOs
│ │ ├── Infrastructure/ // EF configs, repos
│ │ └── Api/ // Controllers or endpoints
│ │
│ ├── MyApp.Modules.Orders/
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── Api/
│ │
│ └── MyApp.Modules.Notifications/
│ ├── Domain/
│ ├── Application/
│ ├── Infrastructure/
│ └── Api/
│
├── MyApp.Shared.Contracts/ // Cross-module interfaces, shared DTOs
└── MyApp.Shared.Infrastructure/ // Common utilities, base classes
Each module is a mini Clean Architecture. Each module has its own DbContext scoped to its tables — modules don't share database contexts.
The golden rule
Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext.
Communication goes through:
- Contracts (interfaces in shared project):
- Domain events (in-process, loose coupling):
// Shared
public record PersonDeletedEvent(int PersonId);
// Persons module publishes (replaced with messaging in microservices)
await _mediator.Publish(new PersonDeletedEvent(id));
// Orders module handles
public class PersonDeletedHandler : INotificationHandler<PersonDeletedEvent>
{
public async Task Handle(PersonDeletedEvent e, CancellationToken ct)
{
// cancel pending orders, clean up references
}
}
Separate DbContexts per module
This is what enforces the boundary at the data level:
// Persons module — only sees Person tables
public class PersonDbContext : DbContext
{
public DbSet<Person> Persons => Set<Person>();
public DbSet<Address> Addresses => Set<Address>();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasDefaultSchema("persons"); // schema isolation
}
}
// Orders module — only sees Order tables
public class OrderDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasDefaultSchema("orders");
}
}
Same database server, different schemas. No cross-module joins. If Orders needs person data, it goes through IPersonModuleApi, not a SQL join.
Coupling vs Cohesion — the two metrics that matter
Coupling = how much modules depend on each other's internals.
| High Coupling | Low Coupling | |
|---|---|---|
| How | Direct references, shared DB context, calling internal methods | Contracts, events, shared DTOs only |
| Change impact | Touch one module -> break three others | Touch one module -> others don't notice |
| Testing | Need to spin up half the app | Test module in isolation |
Cohesion = how related the stuff inside a module is.
| High Cohesion | Low Cohesion | |
|---|---|---|
| How | Everything in the module serves one domain concept | Grab-bag of unrelated utilities |
| Example | PersonService + PersonRepository + PersonValidator | UtilityService with email + tax + image resize |
| Symptom | Module name describes exactly what's inside | Module name is vague ("Helpers", "Common", "Utils") |
The goal: low coupling between modules, high cohesion within modules.
"How do I know if my module boundaries are right?" — the answer is: look at how often a change in one module forces a change in another. If it's frequent, your boundary is in the wrong place. The communication patterns (contracts, events) should cross boundaries rarely, not on every request.
The migration path — why this matters practically
Classic Monolith -> Modular Monolith -> Microservices (if you ever actually need to)
Each module already has its own schema, its own contracts, its own DbContext. Extracting a module into a separate service later means replacing in-process IPersonModuleApi calls with HTTP/gRPC calls, and domain events with a message broker. The code structure barely changes — only the transport layer does.
Most teams never need that last step. The modular monolith gives you 90% of the organizational benefits of microservices with none of the distributed systems pain.
Eventual Consistency
In a monolith with one database, you get immediate consistency — save data, read it back, it's there. One transaction, one commit, done.
The moment you split into modules, services, or separate databases — that guarantee breaks. You get eventual consistency instead: changes propagate, but not instantly. There's a window where different parts of the system see different data.
The classic example
User places an order. Three things need to happen:
- Save the order (Orders module)
- Reserve inventory (Inventory module)
- Send confirmation email (Notifications module)
Immediate consistency approach — distributed transaction:
BEGIN TRANSACTION
Insert order → Orders DB
Decrement stock → Inventory DB
Queue email → Notifications DB
COMMIT
This is a distributed transaction (2PC). All three succeed or all three roll back. Sounds clean. In practice: slow, fragile, doesn't scale, most modern databases and message brokers don't even support it properly.
Eventual consistency approach — events:
1. Orders module saves order → commits to its own DB
2. Orders module publishes OrderPlacedEvent
3. Inventory module handles event → reserves stock in its own DB
4. Notifications module handles event → sends email
Each step is a local transaction — fast, reliable. But between steps 1 and 3, the order exists without reserved inventory. That's the consistency window.
What can go wrong
Timeline:
T0: Order saved → Orders DB has the order
T1: Event published → on the bus
T2: Inventory handler → starts processing
------- consistency window -------
T3: Stock reserved → Inventory DB updated
Between T0 and T3, the system is "inconsistent":
- Orders says: "order exists"
- Inventory says: "stock not reserved yet"
If someone queries inventory at T1, they see stale data. If the inventory handler crashes at T2, the order exists but stock is never reserved.
In the modular monolith context
In-process with MediatR, eventual consistency happens between SaveChangesAsync calls in different modules. The window is small (milliseconds), but it exists.
// Persons module
await _personRepo.DeleteAsync(id);
await _personUow.SaveChangesAsync(); // committed to persons schema
await _mediator.Publish(new PersonDeletedEvent(id));
// Orders module handler — runs after, separate DbContext
public async Task Handle(PersonDeletedEvent e, CancellationToken ct)
{
await _orderRepo.CancelOrdersForPerson(e.PersonId);
await _orderUow.SaveChangesAsync(); // committed to orders schema
// if this fails, person is deleted but orders still exist — inconsistent
}
Why not just use one database transaction
| Scenario | One transaction works? | Notes |
|---|---|---|
| Monolith, one DbContext | Yes | Just use SaveChangesAsync() |
| Modular monolith, same DB server | Maybe | TransactionScope works but couples modules |
| Modular monolith, separate DBs | No | Need events + outbox |
| Microservices | No | Need events + outbox + compensations |
| Third-party APIs (email, payment) | No | Can't roll back an email |
The pragmatic answer: use immediate consistency as long as you can. One DbContext, one SaveChangesAsync. Only go eventual when the architecture forces it — separate modules, separate databases, external services. Don't adopt eventual consistency for the aesthetics.
Look into saga pattern for compensating transactions.
ASP.NET Core Essentials to know
Middleware pipeline — app.UseX() chain, request flows in, response flows out. Order matters. Custom middleware with RequestDelegate.
Filter pipeline — Authorization -> Resource -> Action -> Exception -> Result filters. Like middleware but scoped to MVC actions.
Options pattern — IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>. Strongly-typed config from appsettings.json. No hardcoded connection strings.
Configuration layering — appsettings.json -> appsettings.{Environment}.json -> env variables -> user secrets. Override chain.
Environments — Development, Staging, Production. IWebHostEnvironment, launchSettings.json. Why dev shows exceptions and prod doesn't.
Model binding & validation — [FromBody], [FromRoute], [FromQuery]. Data annotations ([Required], [StringLength]). ModelState.IsValid.
Tag Helpers — asp-for, asp-action, asp-controller, asp-route-id. Razor's way of generating correct HTML from model metadata.
Routing — Convention-based vs attribute routing ([Route], [HttpGet("{id}")]). MapControllerRoute vs MapControllers.
Authentication & Authorization — [Authorize], [AllowAnonymous], Identity, claims, roles, policies. Cookie vs JWT.
EF Core conventions — DbContext lifetime (scoped), migrations workflow (add-migration, update-database), change tracking, Include() for eager loading.
Logging — ILogger<T>, log levels, built-in DI.
Error handling — app.UseExceptionHandler(), app.UseDeveloperExceptionPage(), ProblemDetails for APIs.
Static files — app.UseStaticFiles(), wwwroot. Why CSS doesn't load.
Areas — organizing large MVC apps into feature sections. [Area("Admin")].
ViewComponents & Partial Views — reusable UI chunks. ViewComponents when logic is needed, partials when it's just markup.
MVC vs Razor Pages — MVC for complex apps, Razor Pages for simpler scenarios. Both use the same routing and middleware. Identity scaffolding defaults to Razor Pages even in MVC projects.
Rest APIs — [ApiController], ProblemDetails, IActionResult. Returns data, not views/html. Public, documented, versioned.