All posts

From Layers to Slices

How we moved a layered .NET 7 API to vertical slices, with an AI pair that works inside written rules, automated checks and human decisions."

12 min read
Diagram: before, a payment allocation skips the business layer; after, it is one folder with its own endpoint, validator and handler.

The file nobody wanted to open

Every old codebase has one file people avoid. In this one, it's 11,922 lines long, and everybody just calls it "the controller."

Scroll down to around line 563 and you land on the moment a customer hands over money for a car. In the code, it looks almost shy about what it's doing (names simplified here):

[HttpPost("allocate")]
public IActionResult Allocate([FromBody] Transaction obj)
{
    long result = 0;
    long rows_effected = 0;
    if (obj != null)
    {
        decimal amt = Convert.ToDecimal(obj.amount);
        obj.is_removed = 0;
        using (TransactionData _data = new TransactionData(configuration))
        {
            result = _data.InsertAllocation(obj, amt);
            rows_effected = rows_effected + result;
        }
    }
    return CreatedAtRoute(new { ID = rows_effected }, rows_effected);
}

Read it twice and notice what's missing. Nobody asks whether this receipt actually belongs to this customer. Nobody checks whether there's still money left on it, or whether the order it's being applied to is already paid in full. Maybe the stored procedure behind InsertAllocation asks those questions — but its text lives somewhere outside the repository, which means the honest answer to "where are the rules for this?" is: we don't know. Go and look somewhere else.

That phrase, go and look somewhere else, turned out to be the whole personality of this codebase. And it's the reason we spent the last two weeks taking it apart.

How a house ends up like this

Nobody sat down and designed it to be confusing. The system runs the back office of a vehicle-export business — stock, price inquiries, orders, invoices, customer receipts, shipping bookings, and all the emails that stitch those things together. It started life as a single ASP.NET Core 7 project, built the way the textbooks say to build it: controllers on top, a business layer underneath holding the rules, data access at the bottom.

On paper, tidy. In practice, it felt like a house where the stairs had been moved a few times without telling anyone. Twenty-nine controller files add up to about 24,700 lines. A class called ServiceUnitOfWork hand-builds 21 services with new. A matching UnitOfWork hand-builds 34 repositories. And underneath all of that, three separate roads lead to the same database — EF Core, raw SQL run through EF, and 45 older ADO.NET classes that call stored procedures directly. The payment allocation above takes the shortest of those roads: straight from the controller into a data class, with the business layer never even in the room.

BEFORE · LAYERSControllers29 files · the largest alone: 11,922 linesBusiness layer (BLL)ServiceUnitOfWork builds 21 services with newskipped by this callData access (DAL)34 repositories · 45 ADO.NET *Data.cs classesSQL Serverstored procedures · no foreign keysone payment allocation, traced from controller to databaseAFTER · SLICESPayments moduleits own schema · other modules see only its ContractsDepositEndpointValidatorHandlerAllocateEndpoint25 linesValidator20 linesHandler74 linesRefundEndpointValidatorHandlerTransferEndpointValidatorHandler2 events, via the outboxOrders · Vehicles · Bookings · Notifications
Figure 1. The same errand, before and after. On the left it runs from an 11,922-line controller straight into a data class, skipping the business layer entirely. On the right it's one column in the Payments module, and the rest of the system only hears about it through events.

None of this happened because anyone was careless. This is just what layered code tends to do over time. A deadline shows up, the shortest route from a button to a database table wins, and the layer that was supposed to hold the rules gets quietly walked around. Do that often enough for long enough, and a single feature turns into a thread running through every floor of the house — pull on it, and you end up opening every door.

What we changed the question to

Here's the idea that made the rest of this possible: stop asking which layer does this code belong to, and start asking what is the user actually trying to do right now. Each answer to that second question gets its own folder. That's the whole trick behind what's called a vertical slice, and it's what the new backend — a modular monolith built on an open-source .NET 10 starter kit, still on SQL Server — is built around.

So "allocate a payment" now lives in one place: Payments/Features/v1/CustomerPayments/AllocateCustomerPayment/. Open that folder and you'll find three files and 119 lines, total: an endpoint that answers POST /customer-payments/{id}/allocate, a validator, and a handler. Nothing else. The command itself sits in a small Contracts project that's the only door other modules are allowed to knock on — nobody reaches in and grabs Payments' internals directly.

HTTP requestPOST …/{id}/allocateEndpoint25 linesValidator20 linesHandler74 linesLedgerPUR line postedOutbox2 integration eventssaved in one transaction401 / 403no tokenno Allocate permission400amount not above 0more than 2 decimalsremarks over 200 chars409receipt not depositedreceipt has no currencymore than is left on itorder paid past PI total
Figure 2. One allocation request, walking through the slice. Every step can turn it away, and each refusal carries its own, specific status code. A request that makes it all the way through saves the ledger line and its two events together, in one breath.

Read the handler from the first line to the last, and the rules for allocating a payment are simply there, in plain order, like a short checklist: the receipt has to already be deposited to a customer, and it needs a currency. The amount being allocated can't be more than what's left on the receipt. And the order it's going toward can't already be paid past its proforma invoice total. Clear all four, and the handler posts a PUR line to the ledger and raises two events — one tells Orders, Vehicles and Bookings that a payment changed, the other tells Notifications to email the customer a confirmation. Both events go into a transactional outbox, saved in the same breath as the ledger line, and delivered roughly ten seconds later.

Read the handler
var payment = await dbContext.CustomerPayments
    .FirstOrDefaultAsync(e => e.Id == command.CustomerPaymentId, cancellationToken)
    ?? throw new NotFoundException($"CustomerPayment {command.CustomerPaymentId} not found.");
 
if (payment.CustomerId is not { } owner
    || payment.Status is not (CustomerPaymentStatus.AvailableDeposit or CustomerPaymentStatus.AssignmentOk))
    throw ReceiptRules.Conflict("The receipt is not deposited to a customer.");
 
if (payment.Currency is not { } currency)
    throw ReceiptRules.Conflict("The receipt has no currency.");
 
if (command.Amount > payment.Remaining)
    throw ReceiptRules.Conflict($"The amount is more than the receipt's unallocated rest ({payment.Remaining}).");
 
var order = await OrderPaymentRules.GetOrderWithVehicleAsync(mediator, command.OrderId, command.VehicleId, cancellationToken);
OrderPaymentRules.EnsureValid(order);
if (command.Amount > 0 && order.PaidTotal > order.Amounts.JpPiTotal)
    throw ReceiptRules.Conflict($"The order is already paid more than its PI total ({order.PaidTotal} of {order.Amounts.JpPiTotal}).");
 
payment.Allocate(command.Amount, command.OrderId, command.VehicleId);
LedgerEntry purchase = await ledger.PostAsync(AccTransactionTypeCodes.Purchase, owner, command.Amount, currency, payment.Id, /* … */);
 
events.OrderPaymentChanged(command.OrderId, command.VehicleId, OrderPaymentChange.Allocated, command.Amount, /* … */);
events.PaymentAllocated(purchase.Id, payment.Id, owner, command.OrderId, command.VehicleId, command.Amount, currency, /* … */);
 
await ReceiptRules.SaveAsync(dbContext, cancellationToken);
return purchase.Id;

ConfigureAwait(false) calls and a few arguments are trimmed for reading.

Before the handler even sees the request, the validator has already checked its shape:

RuleFor(x => x.Amount).GreaterThan(0).When(x => !x.IsDummy);
RuleFor(x => x.Amount).Must(a => decimal.Round(a, 2) == a)
    .WithMessage("Amount has at most 2 decimals.");

Modules don't share tables, either. Each one owns its own schema, and when Payments needs to know something about an order, it has to ask — through the Orders Contracts — rather than reach across with a join. Even the old Vehicle table, the kind of table that had quietly grown a column for everything from inspection results to DHL tracking numbers, became something saner: one Vehicle table plus six history tables, where the newest row is simply the current truth.

Rule one: move a room, not a floor

Early on, one rule shaped everything that came after: move one feature at a time, start to finish, never "move all the repositories" and leave the rest for later. That second approach would have just built a new layer cake in a new repository — all the same problems, with a fresh coat of paint.

So the work moved in waves, each one ordered by what leaned on what. First came the lookups that everything else points to — makers, models, colors, fuel types — about forty small global tables gathered into a MasterData module. Then came the bigger rooms: vehicles, customers, orders, payments, inquiries, auto parts, bookings, each one becoming a module of its own.

Endpoints per business modulecount of *Endpoint.cs files in each module, 21 October 20260100200300MasterData: 280 endpointsMasterData280Vehicles: 160 endpointsVehicles160Orders: 44 endpointsOrders44Payments: 39 endpointsPayments39Bookings: 20 endpointsBookings20Customers: 14 endpointsCustomers14AutoParts: 12 endpointsAutoParts12Inquiries: 7 endpointsInquiries7
Figure 3. Endpoints per business module. MasterData looks huge mostly because each of its forty-odd lookup tables gets the same seven generated endpoints — not because it's forty times more complicated than Inquiries.

A lot of the work was repetitive enough that we stopped typing it by hand and handed it to scripts instead. One script creates a new module and wires it into the four places a module has to be registered. Another turns a short JSON spec into a full entity — configuration, endpoints, validators, tests, all generated together. Old rows cross over through MERGE statements keyed on each row's original integer id, so an import can be run twice without duplicating anything, and every run prints the old count next to the new one so nothing silently goes missing. Counted by rows, about 80 percent of the legacy data now lives in the new database — including, improbably, 33,015 vehicle photos and 9,813 vehicle documents that all had to come along for the ride.

The partner that reads everything first

The migration itself was done with Claude, working through Claude Code, as a genuine pair with an engineer. "Pair" is the right word here, as long as you picture it as one partner who types very fast, and another who keeps the keys.

The AI took on the kind of reading that humans tend to run out of patience for. Before writing a single line for any entity, it would go looking — through the controllers, the old *Data.cs classes, the repositories, every fk_ column — and list every place that entity was touched. Then it would profile the live data in a local copy of the staging database: how many distinct values, how many rows quietly holding 0, NULL, 'na', or the old 1999-01-01 "no date" marker, how many orphans, how many duplicates that a new unique index would now reject.

That profiling turned up things the code itself never admitted to. Two old Modify methods — one for vehicles, one for orders — call their stored procedures with fewer parameters than the procedures actually declare. The old data-access helper swallows the resulting error and quietly returns -1, which means every side effect those calls were supposed to trigger has, for who knows how long, simply never run. Had the migration copied that logic line for line, the new system would have started doing things the old one never actually did. In another corner, a reference-number column on order lines looked exactly like it mirrored the vehicle's own reference number — until a quick join counting matches showed it actually equals a different number entirely. Every oddity like this went into a running anomalies file, with counts, examples, and a note on what the import does about it, so a person could decide. That file has grown to nineteen sections.

The AI didn't just read the system — it ran it, too. A small Node driver boots the API, grabs a token, and walks a resource through twelve steps: create it, read it back, find it through search, try to create a duplicate and watch it get rejected, update it, soft-delete it, confirm it's really gone, find it sitting in the trash, restore it, delete it again, and finally confirm that a call without a token gets turned away. If a slice fails any one of those twelve steps, it isn't done — full stop. On busy days, several agents worked at once, each in its own git worktree, each with its own copy of the API running on its own port.

The twelve steps of the CRUD driver
  1. Create the resource: 200
  2. Read it back: 200
  3. Find it through search: 200
  4. Create a duplicate: 409
  5. Update it: 200 or 204
  6. Delete it (soft delete): 204
  7. Read it again: 404
  8. Find it in the trash list
  9. Restore it from the trash
  10. Read it again: 200
  11. Delete it once more
  12. Create without a token: 401

The fence we built around the speed

Here's the honest part of the story: speed was never the hard bit. Keeping fast work correct was. And that rested on four kinds of guardrail, working together like the walls of a fenced yard.

PersonAI agentChecksthe AI proposes and explains the trade-off · a person decides, with an id and a datefirst reads the project rules and the migration skillDecidesid + dateReads, profilesold code, DB copyWrites the slicescripts + handlerBuildwarnings = errorsTestsarch + moduleCRUD driver12 steps, all PASSReviewsand commitsany failure goes back to the AI
Figure 4. Who does what, in order. The AI reads and writes; the machine checks stand at the gate; a person makes the calls that matter and signs off at the end.

Rules, written down and read first. A CLAUDE.md at the root of the repository, ten golden rules in AGENTS.md, and a migration skill that's grown into a 550-line field manual. Between them they say things like: modules only ever talk through their Contracts; every command handler needs a validator standing next to it; never copy an old god table one to one; never touch the remote legacy servers without someone explicitly saying so.

Checks that don't care how confident anyone sounds. A single compiler warning fails the whole build — no exceptions, no "it's probably fine." Fourteen separate classes of architecture tests stand guard over module boundaries, tenant isolation, and endpoint conventions, and make sure every command handler really does have its validator. Every module carries its own test project on top of that. A unit only counts as finished when the build has zero warnings, the tests all pass, the database migration exists for both SQL Server and PostgreSQL, and every single line of the CRUD driver says PASS.

The fourteen architecture test classes
  • API versioning
  • Authorization metadata on every endpoint
  • Shared building blocks stay independent of modules
  • No circular references
  • Contracts projects stay pure
  • Domain entity rules
  • Endpoint conventions
  • Feature folder layout
  • Every command handler has a validator
  • Host project rules
  • Layer dependencies
  • Module boundaries
  • Namespace conventions
  • Tenant isolation

The decisions that stay with a person. Whenever the AI hit a real fork in the road — is this table global, or does it belong to a single tenant? do private documents need their own storage container? — it would lay out the trade-off and simply wait. The answer gets written down with an id and a date, whether it's picking a cloud storage provider or choosing to build fresh identities rather than import old passwords. One lesson is now spelled out for the AI in plain words, because it had to be learned once: a recommendation sitting in a plan document is not a decision until a person actually confirms it.

Mistakes that turn into rules. Whenever something went wrong in a way that could happen again, it got written straight into the skill's list of gotchas. The best example is a quiet one, almost invisible: the migration script builds the project before it writes the new migration files, so running --no-build right after adding one silently reused the old assembly. It printed "migrating," finished without complaint, and created nothing at all. It looked exactly like success. The skill now insists on a rebuild after every new migration, and notes that a real apply logs the words "applied migrations" — if you don't see that, you built nothing.

One more guardrail faces the team rather than the code. Every change to the database, a workflow, or the architecture also earns an entry in a change log written in plain Bengali, for colleagues who will never open a C# file in their lives: how it used to work, how it works now, why it changed, an example, and the date.

Where the rules live now

Go back to the question this whole story opened with: where are the rules for allocating a payment? Today, the answer is one folder, three files, and a handler you can read start to finish in under a minute. When a new rule shows up — say, a cap on dummy allocations — there's exactly one obvious place to put it, with a validator sitting right beside it that the architecture tests simply won't let you forget.

The migration isn't finished. Roles and menus are still waiting on a team decision, and the auction database stays exactly where it is, read-only for now. But the shape of the thing is settled, and so is the way of working: the AI moves fast through the reading, the scaffolding, and the checking — and people keep deciding where the fences stand.

Related reading