From layers to vertical slices
Simplify your code and focus on your features
Jon Hilton
https://practicaldotnet.io
https://jonhilton.net
@jonhilt
"The Matrix" Copyright © 1999 Warner Bros
Could you just... ?
@jonhilt
@jonhilt
@jonhilt
@jonhilt
@jonhilt
"We need to find Mr Anderson..."
@jonhilt
"The Matrix" Copyright © 1999 Warner Bros
First Name | Last Name(s) | Last Slept | Threat Level |
---|---|---|---|
Jon | Hilton | Today | Harmless |
Brian | Blessed | 2 Days Ago | A bit loud |
Not | The One | ? | ? |
The lady | In the Red Dress | Never | Simulation |
@jonhilt
?
ASP.NET/
Presentation
PersonController
ListAll
@jonhilt
ASP.NET
PersonController
ListAll
ListAll
PersonService
ListAll
PersonRepository
Business Logic
Data Access
@jonhilt
@jonhilt
public class PersonController : Controller
{
private readonly PersonService personService;
public PersonController(PersonService personService)
{
this.personService = personService;
}
[HttpGet]
public async Task<IActionResult> List()
{
var allPeople = personService.ListAll();
return View(allPeople);
}
}
@jonhilt
public class PersonService
{
public List<Person> ListAll()
{
return new List<Person>
{
new Person
{
FirstName = "Neo",
LastName = "Anderson",
LastSlept = new DateTime(2021, 1, 1, 10, 15, 0),
ThreatLevel = "Inconsequential"
}
};
}
}
@jonhilt
@model System.Collections.Generic.List<NeoFindr.Controllers.Person>
<table class="table table-striped">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Last Slept</th>
<th>Threat Level</th>
</tr>
</thead>
<tbody>
@foreach (var person in Model)
{
<tr>
<td>@person.FirstName</td>
<td>@person.LastName</td>
<td>@person.LastSlept</td>
<td>@person.ThreatLevel</td>
</tr>
}
</tbody>
</table>
@jonhilt
@jonhilt
"Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer.
Repository also supports the objective of achieving a clean separation and one-way dependency between the domain and data mapping layers."
Patterns of Enterprise Application Architecture by Martin Fowler et al
@jonhilt
@jonhilt
public class PersonService
{
public List<Person> ListAll()
{
return new List<Person>
{
new Person
{
FirstName = "Neo",
LastName = "Anderson",
LastSlept = new DateTime(2021, 1, 1, 10, 15, 0),
ThreatLevel = "Inconsequential"
}
};
}
}
@jonhilt
public class PersonRepository
{
public List<Person> ListAll()
{
return new List<Person>
{
new Person
{
FirstName = "Neo",
LastName = "Anderson",
LastSlept = new DateTime(2021, 1, 1, 10, 15, 0),
ThreatLevel = "Inconsequential"
}
};
}
}
public class PersonService
{
public PersonService(PersonRepository personRepository)
{
this.personRepository = personRepository;
}
public List<Person> ListAll()
{
return personRepository.ListAll();
}
}
"adding this layer helps minimise duplicate query logic..."
Patterns of Enterprise Application Architecture by Martin Fowler et al
@jonhilt
"There are too many people to work through; we need a way to search"
@jonhilt
"The Matrix" Copyright © 1999 Warner Bros
First Name | Last Name(s) | Last Slept | Threat Level |
---|---|---|---|
The One | And Only | Today | ? |
Maybe | The One | 1 Hour Ago | Always asleep |
Not | The One | ? | ? |
Thomas | Anderson | Yesterday | Presumably Harmless |
Search term
Showing 4 of 8 results...
The one
@jonhilt
ASP.NET/
Presentation
PersonController
ListAll
ListAll
PersonService
ListAll
PersonRepository
Business Logic
Data Access
@jonhilt
Search
Search
Developers are LAZY...
(in a good way!)
Developers are LAZY...
People
https://www.behaviormodel.org/
ASP.NET/
Presentation
PersonController
ListAll
ListAll
PersonService
ListAll
PersonRepository
Business Logic
Data Access
Search
Search
@jonhilt
@jonhilt
public class PersonController : Controller
{
private readonly PersonService personService;
public PersonController(PersonService personService)
{
this.personService = personService;
}
[HttpGet]
public async Task<IActionResult> List()
{
var allPeople = personService.ListAll();
return View(allPeople);
}
[HttpGet]
public async Task<IActionResult> Search(string searchTerm)
{
var results = personService.Search(searchTerm);
return View("List", results);
}
}
@jonhilt
public class PersonService
{
private PersonRepository personRepository;
public PersonService(PersonRepository personRepository)
{
this.personRepository = personRepository;
}
public List<Person> ListAll()
{
return personRepository.ListAll();
}
public List<Person> Search(string searchTerm)
{
return personRepository.ListAll(searchTerm);
}
}
@jonhilt
public class PersonRepository
{
public List<Person> ListAll(string searchTerm = "")
{
if (!string.IsNullOrEmpty(searchTerm))
{
// pass query to DB...
}
else
{
// return everything?
}
return new List<Person>
{
new Person
{
FirstName = "Neo",
LastName = "Anderson",
LastSlept = new DateTime(2021, 1, 1, 10, 15, 0),
ThreatLevel = "Inconsequential"
}
};
}
}
"This search is broken; it keeps showing us agents as well as inhabitants.
Show us the agents separately!!"
@jonhilt
"The Matrix" Copyright © 1999 Warner Bros
"Sentient programs. They can move in and out of any software still hardwired to the system. That means that anyone we haven't unplugged...is potentially an Agent. Inside the Matrix, they are everyone...and they are no one."
First Name | Last Name | Uptime |
---|---|---|
Agent | Smith | 6 years |
Agent | Jackson | 2 years |
Agent | Thompson | 3 months |
Agent List
@jonhilt
PersonController
ListAll
ListAll
PersonService
ListAll
PersonRepository
Search
Search
AgentController
ListAll
AgentService
ListAll
@jonhilt
if(person.Uptime != null){
// do stuff
}
if(person.FirstName == "Agent"){
// do stuff
}
@jonhilt
PersonController
ListAll
ListAll
PersonService
PersonRepository
Search
AgentController
ListAll
AgentService
Add
Search
ListAll
Add
Add
ListAll
Destroy
Destroy
Destroy
Decommission
Decommission
@jonhilt
BIG classes
Lots o' references
- Suddenly, people actually means Inhabitants (at service and controller level)
- The person repository is used for both agents and inhabitants (are both, either, or non actually people?)
- Conditional logic to figure out which "mode" we're in (destroying inhabitants or decommissioning agents)
Domain Model?
What Domain Model?
@jonhilt
Helpers
Mappers
Managers
Factories
ServiceFactories
Functions
Requests
Responses
Models
ViewModels
DALS
Readers
Repositories
ReadOnlyRepositories
FactoryFactories
Utilities
Death Star II from "Star Wars: Return of the Jedi"
Copyright © 1983 Lucasfilm Ltd.
Let's start over!
@jonhilt
"We need to find Mr Anderson..."
@jonhilt
"The Matrix" Copyright © 1999 Warner Bros
?
@jonhilt
Ask more questions
Inhabitants
The Matrix
Agents
Computer programs
NEVER sleep
Crash occasionally
Good uptime
@jonhilt
First Name | Last Name(s) | Last Slept | Threat Level |
---|---|---|---|
Jon | Hilton | Today | Harmless |
Brian | Blessed | 2 Days Ago | A bit loud |
Not | The One | ? | ? |
The lady | In the Red Dress | Never | Simulation |
Inhabitants
@jonhilt
@jonhilt
"Traditional" MVC Folder Structure
@jonhilt
?
ASP.NET/
Presentation
InhabitantController
ListAll
ASP.NET/
Presentation
InhabitantController
ListAll
ListAll
Request (query)
Response (model)
Inhabitants
@jonhilt
@jonhilt
@jonhilt
ASP.NET/
Presentation
InhabitantController
ListAll
ListAll
Request (query)
Response (model)
Inhabitants
Search
Search
Term (string)
SearchResults
@jonhilt
@jonhilt
public class Search
{
public class Query : IRequest<Model>
{
public string Term { get; set; }
}
public class Model
{
public List<SearchResult> Results { get; set; }
public class SearchResult {
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime LastSlept { get; set; }
public string ThreatLevel { get; set; }
public Guid Id { get; set; }
}
}
}
@jonhilt
public class QueryHandler : IRequestHandler<Query, Model>
{
private readonly FindRContext _dbContext;
public QueryHandler(FindRContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Model> Handle(QueryHandler request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.Term))
return new Model();
var results = _dbContext.Inhabitants.Where(
x => x.FirstName.contains(request.Term) || x.LastName.Contains(request.Term));
return new Model
{
Results = results.Select(results => new Model.SearchResult{
FirstName =result.FirstName,
LastName = result.LastName,
LastSlept = result.LastSlept,
ThreatLevel = results.CurrentThreatLevel,
Id = result.Id
}).ToList();
}
}
}
@jonhilt
[HttpGet]
public async Task<IActionResult> Search(Search.Query query){
var model = await _mediator.Send(query);
return View(model);
}
ASP.NET/
Presentation
InhabitantController
ListAll
ListAll
Request
(command)
Inhabitants
Search
Search
Destroy
Destroy
Destroy!
public class InhabitantController : Controller
{
private readonly IMediator _mediator;
public InhabitantController(IMediator mediator)
{
_mediator = mediator;
}
---
[HttpPost]
public async Task<IActionResult> Destroy(Destroy.Command command)
{
await _mediator.Send(command);
return RedirectToAction(nameof(Search));
}
}
Destroy!
public class Destroy
{
public class Command : IRequest
{
public Guid Id { get; set; }
}
public class CommandHandler : AsyncRequestHandler<Command>
{
protected override async Task Handle(Command request,
CancellationToken cancellationToken)
{
}
}
}
Destroy!
public class Destroy
{
public class Command : IRequest
{
public Guid Id { get; set; }
}
public class CommandHandler : AsyncRequestHandler<Command>
{
private readonly FindRContext findRContext;
public CommandHandler(FindRContext dbContext)
{
findRContext = dbContext;
}
protected override async Task Handle(Command request,
CancellationToken cancellationToken)
{
var inhabitant = await findRContext.Inhabitants.FindAsync(request.Id);
findRContext.Inhabitants.Remove(inhabitant);
await findRContext.SaveChangesAsync(cancellationToken);
}
}
}
Encapsulate by feature
InhabitantController
ListAll
ListAll
Search
Search
Destroy
Destroy
InhabitantController
InhabitantController
@jonhilt
Refactor
by feature
@jonhilt
Destroy
Inhabitant
Destroy()
public void Destroy(){
ThreatLevel = "Extinguished";
Expired = DateTime.Now;
DomainEvents.Raise(new InhabitantDestroyedEvent
{
InhabitantId = Id,
DestroyedOn = Expired
});
}
Handler
Aggregate
Extend and Refactor
Handling Side Effects
Avoid links between features
(specific, business logic)
@jonhilt
PilotController
List
Pilot
Search
Retire
List
Search
Retire
AttendantController
ListBy
Rank
Add
Update
Address
List
Add
Update
Address
Flight
Attendant
Address
@jonhilt
Pilot (aggregate)
Dapper
Query
Dapper
Query
Dapper
Query
Dapper
Query
EF Core
public class Flight
{
public List<Guid> Attendants { get; private set; }
public Guid Pilot { get; private set; }
public Guid CoPilot { get; private set; }
public DateTime Departed { get; private set; }
public void AssignAttendant(Guid Id)
{
this.Attendants.Add(Id);
}
public void Depart()
{
if (Pilot == null || CoPilot == null)
throw new InsufficientFlightCrewException();
Departed = DateTime.Now();
}
}
Missing Models
What about tests?
@jonhilt
PersonController
ListAll
ListAll
PersonService
PersonRepository
Search
AgentController
ListAll
AgentService
Add
Search
ListAll
Add
Add
ListAll
Destroy
Destroy
Destroy
Decommission
Decommission
@jonhilt
PersonController
PersonService
PersonRepository
Destroy
Destroy
Destroy
Test?
Test in isolation
Test?
PersonController
Destroy
Destroy
Inhabitants
Unit Tests
- Great for business logic
- Can test aggregates
- (no side effects!)
PersonController
Destroy
Destroy
Inhabitants
When destroying an inhabitant
They should be completely expunged from the system
Integration/Scenario Tests
The names used are those of the business domain (feature)
@jonhilt
@jonhilt
public class WhenDestroyingAnInhabitant : IntegrationTestBase
{
[Fact]
public async void TheyShouldBeCompletelyExpunged()
{
var inhabitantId = Guid.NewGuid();
await SliceFixture.InsertAsync(new Inhabitant {Id = inhabitantId});
await SliceFixture.SendAsync(new Destroy.Command {Id = inhabitantId});
var savedInhabitant = await SliceFixture.FindAsync<Inhabitant>(inhabitantId);
savedInhabitant.ShouldBeNull();
}
}
5 Practical Tips
-
Ask more questions! (to understand what you're trying to build)
-
Avoid organising by layers or technical concerns (especially early on)
-
Embrace Command Query Separation
-
Leverage MediatR (start your features off on the right foot)
-
Refactor on a per feature basis
-
(BONUS) Use Domain Events to decouple parts of your system
There is no "best" design.
There is only the "best design given our current understanding"
Jimmy Bogard - "Strengthening your domain"
https://bit.ly/NoBestDesign
Examples in the wild
Humanitarian Toolbox - AllReady
Jimmy Bogard - Contoso University
https://github.com/jbogard/ContosoUniversityDotNetCore
Tackling Business Complexity in a Microservice with DDD and CQRS Patterns
@jonhilt
jonhilton.net/slices
jon@jonhilton.net
@jonhilt
From layers to vertical slices 2.0
By jonhilt
From layers to vertical slices 2.0
- 510