VERICODE vericode.com.au

No Services, no Repositories: vertical slices in .NET

Chris Love · 4 August 2026


No Services, no Repositories: vertical slices in .NET

The Vericode API has around sixty features in it. It has no Services/ folder, no Repositories/ folder and no Domain/ project.

I spent over a decade building .NET systems the other way, so this wasn’t ignorance. Maybe a little laziness.. but the good kind, and I’ll defend it.

The layer cake

The default way to structure a .NET API is layers. Controllers/, Services/, Repositories/, Domain/, Infrastructure/. Every feature is a horizontal cut across all five: the controller calls the service, the service calls the repository, and somewhere along the way the same object gets mapped three times.

I built systems like that for years and I understand the appeal. Nobody ever got fired for adding a service layer.

The problem showed up when I became the whole team. When I want to know what happens when a delivery report arrives, the layered answer is “open five folders and hold the map in your head”. My coding sessions on any given feature can be weeks apart. The map goes stale. I got sick of paying that tax and stopped.

What I do instead

Code is organised by feature, not by layer. Each feature is one folder under Features/:

Features/
  CreateProvider/
    CreateProviderEndpoint.cs
    CreateProviderRequest.cs
    CreateProviderResponse.cs
    CreateProviderValidator.cs
  SendSms/
  CheckVerification/
  DlrWebhook/
  ProcessDeliveryReport/
  RecordTopUp/
  ...

The folder has everything the feature needs. Endpoint (or message consumer, for the bus-driven ones), request and response records, validator, data access. The folder is the unit of comprehension.

Here’s a real one, lightly trimmed:

public sealed class CreateProviderEndpoint
    : Endpoint<CreateProviderRequest, CreateProviderResponse>
{
    private readonly NpgsqlDataSource _db;
    private readonly IPublishEndpoint _publisher;
    private readonly IEventLogger _eventLogger;

    public override void Configure()
    {
        Post("/api/providers");
        PreProcessor<AdminGuardPreProcessor<CreateProviderRequest>>();
    }

    public async Task<Result<CreateProviderResponse>> HandleCoreAsync(
        CreateProviderRequest req, Guid workspaceId, CancellationToken ct)
    {
        await using var connection = await _db.OpenConnectionAsync(ct);

        var existing = await connection.QuerySingleOrDefaultAsync<int>(
            "SELECT COUNT(1) FROM sms_providers WHERE slug = @Slug", new { req.Slug });

        if (existing > 0)
            return Result.Fail<CreateProviderResponse>(
                new ConflictError($"Provider with slug '{req.Slug}' already exists."));

        var id = ProviderId.New();
        var now = DateTimeOffset.UtcNow;

        await connection.ExecuteAsync("""
            INSERT INTO sms_providers (provider_id, workspace_id, name, slug,
                status, adapter_type, api_config, created_at, updated_at)
            VALUES (@ProviderId, @WorkspaceId, @Name, @Slug,
                'Active', @AdapterType, @ApiConfig::jsonb, @Now, @Now)
            """, new { ProviderId = id.Value, WorkspaceId = workspaceId,
                req.Name, req.Slug, req.AdapterType,
                ApiConfig = req.ApiConfig ?? "{}", Now = now });

        var evt = new ProviderRegistered(id.Value, req.Name, req.Slug, req.AdapterType, now);
        await _publisher.Publish(evt, ct);
        await _eventLogger.LogEventAsync("ProviderRegistered", "SmsProvider", id.Value, evt);

        return Result.Ok(new CreateProviderResponse(
            id.Value, req.Name, req.Slug, "Active", req.AdapterType, now));
    }
}

Yes, that’s SQL in the endpoint. Parameterised Dapper, sitting right where you can read it. Conflict check, insert, publish the event, write the audit log, done.

The request is one line:

public sealed record CreateProviderRequest(
    string Name, string Slug, string AdapterType, string? ApiConfig);

There’s no mapping layer because there’s nothing to map to.

Extract on the second use

The objection everyone raises is duplication. Two features will eventually want the same query, and without a repository layer, where does it live?

My rule: nothing gets extracted until the second use.

One use is a coincidence. Two uses are a pattern, and a pattern earns a shared home.

When one feature needs a query, the query lives in that feature’s folder. When a second feature wants it, that’s when the query moves, and moving it is a deliberate decision instead of a reflex. Until then a bit of duplication is fine. Two features that happen to share a query shape don’t need to share a dependency, and shared dependencies are the thing that actually hurts later.

The defensive version (“I made a ProviderService because we might need it later”) gets the same answer every time: delete it, extract on the second use. I have to hold this line in code review, even now that the reviewer is usually a bot. Since I started building this way, the number of times I’ve regretted waiting is zero. The number of premature abstractions I’ve had to unwind in past layered codebases is.. a lot more than zero.

Where shared code goes

Slices don’t mean no shared code. They mean shared code has to earn its spot. Two folders exist alongside Features/:

Infrastructure/ holds the genuinely cross-cutting plumbing: DI registration, correlation IDs, the transactional outbox, the append-only event log, auth pre-processors. Things that are infrastructural by nature, not business logic that got homesick.

Adapters/ holds the SMS provider implementations behind one contract interface, because we actually do swap providers.

Commands and events that other modules consume live in a separate contracts assembly. Other modules see the contracts and nothing else, but that’s a post of its own.

The stack does a lot of the lifting

You can do vertical slices with MVC and EF. It’s just harder to keep them thin.

FastEndpoints is one class per endpoint, which maps straight onto one folder per feature. MediatR would give me the slicing too, but I’d be paying for it with a request class, a handler class and a pipeline of behaviours to debug through. FastEndpoints pre-processors already cover the cross-cutting cases, so the mediator would be a tax on decoupling I don’t need.

Dapper keeps the data access small enough to live inside the slice. When a query is eight readable lines of SQL there’s nothing to hide. Half the reason repositories exist is to quarantine an ORM.

Agents stay in their lane

Here’s the part I didn’t see coming when I drew these boundaries: most of Vericode’s code is now written by coding agents. Claude Code and codex do the typing, I do the directing and the reviewing. And the architecture choice I made for my own sanity turned out to matter even more for theirs.

An agent inherits your codebase’s habits at machine speed. In a layered codebase, “add a field to provider config” is an invitation to touch the controller, the service, the repository, the domain object and the mapping profile. An agent accepts that invitation every single time. Then, while it’s got ProviderService.cs open, it notices three other methods it could tidy up.. and my one field goes in, forty files come back.

With slices, the instruction “add X to CreateProvider” maps to one folder. The agent reads one folder, edits one folder, and the diff comes back the same shape as the request. Any file outside Features/CreateProvider/ in that diff is the first thing I question in review. The folder boundary turns “did the agent go rogue” from a careful read of the whole diff into a glance at the file list.

There’s a quieter benefit underneath that. Before an agent can safely change a layered codebase it has to load the layer map into its head, same as a human. An agent working a slice loads one folder. Smaller context means fewer wrong guesses and cheaper, faster runs.

The discipline that stops me over-editing is exactly the discipline that stops the agents over-editing. I just didn’t know I was building guardrails for robots at the time.

The trade-offs

Developers arriving from layered codebases reflexively reach for extraction, and the convention needs defending or it erodes. Some duplication genuinely exists and you have to make peace with it. And the discipline only holds inside a module boundary.

What I get back: adding a feature is one new folder, reading a feature is one open folder, and an agent’s diff is one changed folder. When your sessions on a piece of code are weeks apart, opening one folder and knowing everything beats spending the morning re-learning your own system.

If you take one thing from this, take the rule. Nothing gets extracted until the second use. It has done more for this codebase than any framework choice I’ve made.