Vertical slice architecture pitch

For the past year I’ve been playing with Jimmy Bogard’s vertical slice architecture on a personal project and I became a fan. I recently recommended we start using it at work and after the initial positive feedback I was asked to prepare a bit of a pitch for my co-workers. So I decided to document my thoughts here. This will be mostly just a bunch of notes, references and personal observations to help me preach. If you find it useful, great! If you have anything to add or object to, please leave a comment. Thanks!

Naturally, I think the best pitch really is to just watch Jimmy explain it. I’ll leave these 2 videos here for your pleasure:



What we currently have (N-Tier)

In very generic terms, this is how N-Tier is typically represented (image taken from Jimmy):

N-Tier ~ Bogard

And here is an overview of how we currently structure our projects:

WebAPI layer

  • Application bootstrapping / configuration
  • Simple Authentication / Authorization through the [Authorize] .Net attribute
    • Ok for simple authentication / user role checks
  • Simple model validation through a custom [ValidateModelState] controller action attribute
  • Routing
  • Global exception handler

Business layer

  • Services
    • Business (domain) logic
    • Invocation of Entity-to-View-Model mappings
    • More complex authorization which typically requires a trip to the DB to:
      • Ensure user is authorized to access / modify a resource
      • Ensure user belongs to a company
      • Ensure IP whitelisted
    • More complex model validation
      • FluentValidation - More complicated validation rules that can’t be accomplished using basic .Net validation attributes
      • Trips to the DB to validate the model
        • Ensure a DB record with the same name does not already exist
        • Ensure user didn’t already fulfill his allowed user space / quota
  • Providers
    • Email sender
    • SMS sender
    • 3rd party API’s adapters
  • Data Mappers
    • Static Entity-to-View-Model mapper classes
    • Shared, domain data-mapping logic

Auth layer

  • Common security providers
    • Complex security checks that require trips to the DB
    • Typically consumed by business layer services
  • Custom auth / security infrastructure code

Repository layer

  • Base and generic repository classes
    • Basically generic LLBLGen ORM wrapper code
  • Specific repositories built on top of our IRepository<T> such as:
    • IUserRepository
    • ICustomerRepository
    • IOrderRepository
  • Specific lookup repositories built in top our ILookupRepository<T> such as:
    • IProductTypeLookup
    • IUserRoleLookup

Core layer

  • Cross-cutting (accessible by WebAPI / Business / Repository layers)
  • GET models, POST/PUT models, grid filtering models
  • Custom exception types
  • Common extension / helper classes for primitive / basic types

Identified issues

Too many scattered files have to be created / touched to add a new feature

  • Files not close together in the project structure, but instead scattered
  • A lot of ceremony, not necessarily that much value
    • Is the structure improving things or is it being in the way of doing work?
  • Wastes time
  • Adds to cognitive load
  • Annoying
  • Files that typically require touching / creation to add a new feature:
    • Controller
    • Service interface
    • Service implementation class
    • If the service is a brand new one - DI binding needs to be added to the DI composition
    • Repository interface
    • Repository implementation class
    • If the repository is a brand new one - DI binding needs to be added to the DI composition
    • Model class
    • Entity-to-Model mapping class

Fuzzy separation of concerns as the project grows

  • Initially very specific pieces of code start doing too much and being too abstract for the sake of being reused
  • This is bad code reuse which leads to:
    • Bloated god object models
    • Bloated repository methods
      • Multi-level fetching bloat (prefetching in LLBLGen) - too much data being fetched from the DB even when it’s not all needed
      • Filtering becomes too complex / too smart
      • Many filtering options have to become “optional”
    • Changes in one feature can have undesired effects and break other features which reuse the same shared abstraction

Authorization / Model validation scattered between 2 layers

  • Harder to reason about - at least 2 places (a controller and a service) need to be checked to get the full picture and to be sure what the rules are
  • These rules are arguably part of the business logic and have no place being in controllers
  • If multiple applications rely on the same business layer, this could greatly degrade maintainability
    • Part of the functionality could get overlooked because it’s in controllers
    • Other applications will have to somehow still apply the rules
    • Rules could easily get out of sync because logic is not in one place
  • Harder unit testing
    • Controllers are not typically being unit tested
    • Attribute behavior is weird to unit test
    • Since rules are not applied in the same place, how do you even test it as a “unit”?

Exception based code flow

  • Basically GOTO statements on steroids
  • Hard to predict and follow and sometimes a little more inconvenient to unit test
  • An implicit way of dealing with code flow through side-effects
  • Exceptions obfuscate possible outcomes of invoking a method - how do we know what will be thrown?
  • Exceptions should stay exceptions that occur under unexpected circumstances, not become rules for handling code flow

Vertical Slice

We use SPA’s on client-side. There, we already started grouping code by features a long time ago because it’s just much more convenient. Module routing, service code, controllers, templates, components most often all get grouped together inside a dedicated folder. VueJS and React even go as far to encourage putting controllers, templates and styles into a single file.

Vertical slice architecture that Jimmy Bogard describes is in a way similar to that - why would we not bundle things together on the backend?

Vertical Slice ~ Bogard

Vertical Slice Detail ~ Bogard

New, shiny stuff

  • CQRS - Command Query Responsibility Segregation
  • MediatR - Decoupling of in-process sending of messages from handling messages
    • Gives uniform structure to the whole team
    • Easy behavior pipelines (cross-cutting concerns)
  • Result type - Replaces exception-based code flow

CQRS

CQRS

  • Queries read but don’t mutate data
    • Safe, idempotent
  • Commands create / mutate but don’t return data
    • Unsafe, not idempotent
    • I actually broke this rule slightly to allow returning of the Id of newly created entities, but nothing more
  • Typically now two models where once there was one (which would be reused)
  • SEE: CQS vs. CQRS
  • Behavior pipelines handle cross-cutting concerns
    • Authentication
    • Authorization
    • Validation
    • Transactions
    • Caching
    • Retries
    • Logging

Solutions to previous N-Tier issues

Reduced number of files required per feature

A feature implemented using vertical slicing typically requires only 2 files to be touched / created:

  • A controller action entry for routing purposes / message dispatching
  • A command / query handler file which contains:
    • Query / Command model class
    • Custom validator method (optional)
    • Custom authorization method (optional)
    • Query / Command handler class
      • ORM code
      • Business logic
      • Entity-to-Model mapping
    • Query return model (for queries only)
    • Having a single file for all this is OK

Instead of using specific repositories:

  • Handlers inject generic repositories
  • Each handler writes its own ORM logic
  • For common ORM code, better to write very specific prefetch / bucket builder extension methods (pure functions)
    • Avoid for trivial cases
    • Avoid for low reused cases
      • Maybe not really that common after all?
    • Introduces coupling, so be careful
    • “User profile” prefetch is an example of a good candidate
      • AspNetUser + UserDetail + AspNetUserRoles + AspNetRoles

Note how we no longer need all the service / repository interface files.

For complex, heavy domain logic that will see heavy reuse, it’s ok to use a separate *.Domain project and to use use pure, rich domain models. These can then be shared by handlers. In many cases, this is not really necessary however as the logic is often simple enough to stay self-contained inside a handler - only introduce abstractions when there are pain-points.

Clear separation of concerns

Each feature is in a way isolated and self-contained.

  • Each feature has its own view model
  • No more bloated god object models
  • No more bloated repository methods
  • Changing one feature no longer affects / breaks other features

Authorization / Model validation in one place

  • Easier to reason about
  • No need to worry that part of the implementation will be overlooked in other applications
  • Can be easily tested

Result<T> based code flow

  • Explicit code flow
  • More predictable - the return type is known (compared to an unexpected exception throw) and the invoking side knows it needs to handle it
  • Less side-effects
  • SEE: Railway oriented programming

Typical handlers

Basic get item query

public class GetProduct
{
    public class Query : IRequest<Result<Model>>, IOwnerAuthorizedRequest
    {
        public int Id { get; set; }

        public Query(int id)
        {
            Id = id;
        }
    }

    public class OwnerAuthorizer : OwnerRequestAuthorizer<Command>
    {
        private readonly IRepository<ProductEntity> _productRepository;

        public OwnerAuthorizer(IAppContext appContext, IRepository<ProductEntity> productRepository) : base(appContext)
        {
            _productRepository = productRepository;
        }

        protected override async Task<int?> GetResourceOwnerIdAsync(Command request)
        {
            var entity = await _productRepository.GetEntityAsync(request.Id);

            return entity?.CreatedById;
        }
    }

    public class Handler : IRequestHandler<Query, Result<Model>>
    {
        private readonly IRepository<ProductEntity> _productRepository;

        public Handler(IRepository<ProductEntity> productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<Result<Model>> Handle(Query query, CancellationToken cancellationToken)
        {
            var bucket = new RelationPredicateBucket();
            bucket.PredicateExpression.Add(ProductFields.Id == query.Id);

            var entity = (await _productRepository.GetEntitiesAsync(bucket)).FirstOrDefault();

            return ToModel(entity);
        }

        private static Result<Model> ToModel(ProductEntity entity)
        {
            if (entity == null)
            {
                return new NotFoundError();
            }

            var model = new Model
            {
                Id = entity.Id,
                Name = entity.Name
            };

            return model;
        }
    }

    public class Model
    {
        public int Id { get; set; }

        public string Name { get; set; }
    }
}

Basic filter items query (for grid)

public class FilterProducts
{
    public class Query : GridFilter, IRequest<Result<GridCollection<Model>>>, IAuthenticatedRequest
    {
    }

    public class Handler : IRequestHandler<Query, Result<GridCollection<Model>>>
    {
        private readonly IAppContext _appContext;
        private readonly IRepository<ProductEntity> _productRepository;

        public Handler(IAppContext appContext, IRepository<ProductEntity> productRepository)
        {
            _appContext = appContext;
            _productRepository = productRepository;
        }

        public async Task<Result<GridCollection<Model>>> Handle(Query query, CancellationToken cancellationToken)
        {
            var parameters = query.ToParameters();
            var bucket = new RelationPredicateBucket();
            bucket.PredicateExpression.Add(ProductFields.CreatedById == _appContext.Identity.User.Id);

            var grid = await _productRepository.FetchGridEntitiesAsync(parameters, bucket);

            return grid.ReMap(ToModel);
        }

        private static Model ToModel(ProductEntity entity)
        {
            if (entity == null)
            {
                return null;
            }

            var model = new Model
            {
                Id = entity.Id,
                Name = entity.Name
            };

            return model;
        }
    }

    public class Model
    {
        public int Id { get; set; }

        public string Name { get; set; }
    }
}

Basic create item query

public class CreateProduct
{
    public class Command : IRequest<Result<int>>, IAuthenticatedRequest
    {
        [Required]
        public string Name { get; set; }
    }

    public class Handler : IRequestHandler<Command, Result<int>>
    {
        private readonly IAppContext _appContext;
        private readonly IRepository<ProductEntity> _productRepository;

        public Handler(IAppContext appContext, IRepository<ProductEntity> productRepository)
        {
            _appContext = appContext;
            _productRepository = productRepository;
        }

        public async Task<Result<int>> Handle(Command command, CancellationToken cancellationToken)
        {
            var entity = new ProductEntity
            {
                CreatedById = _appContext.Identity.User.Id,
                UpdatedById = _appContext.Identity.User.Id,
                Name = command.Name
            };

            await _productRepository.SaveEntityAsync(entity, true);

            return entity.Id;
        }
    }
}

Basic update item query

public class UpdateProduct
{
    public class Command : MediatR.IRequest<Result<Unit>>, IOwnerAuthorizedRequest
    {
        public int Id { get; set; }

        [Required]
        public string Name { get; set; }
    }

    public class OwnerAuthorizer : OwnerRequestAuthorizer<Command>
    {
        private readonly IRepository<ProductEntity> _productRepository;

        public OwnerAuthorizer(IAppContext appContext, IRepository<ProductEntity> productRepository) : base(appContext)
        {
            _productRepository = productRepository;
        }

        protected override async Task<int?> GetResourceOwnerIdAsync(Command request)
        {
            var entity = await _productRepository.GetEntityAsync(request.Id);

            return entity?.CreatedById;
        }
    }

    public class Handler : MediatR.IRequestHandler<Command, Result<Unit>>
    {
        private readonly IAppContext _appContext;
        private readonly IRepository<ProductEntity> _productRepository;

        public Handler(IAppContext appContext, IRepository<ProductEntity> productRepository)
        {
            _appContext = appContext;
            _productRepository = productRepository;
        }

        public async Task<Result<Unit>> Handle(Command command, CancellationToken cancellationToken)
        {
            var entity = await _productRepository.GetEntityAsync(command.Id);

            entity.UpdatedAt = DateTime.UtcNow;
            entity.UpdatedById = _appContext.Identity.User.Id;
            entity.Name = command.Name;

            await _productRepository.SaveEntityAsync(entity);

            return Result.Ok();
        }
    }
}

Downsides

  • More models (one per handler)
    • Get/create/update will have their own, separate models
    • This is a tradeoff towards clearer separation of concerns and bloat mitigation
    • More mapping code (unless AutoMapper is used) as a result of more models
  • More files
  • New paradigm, teams needs to be trained a bit
  • Might potentially be a bit harder for Junior developers
    • Not the default .Net documentation architecture
    • Not the default Visual Studio .Net scaffold
    • Less code samples online

Other

  • Suitable for event sourcing
  • Suitable for microservices
  • Multiple ORMS can be used easily at the same time if needed
  • If there’s a need to swap out an ORM (most often there is not), it can easily be done gradually
  • Routing can be simplified even further by removing most of the controllers and using
  • Feature code reuse - avoid, infrastructure code - reuse

Additional resources