Mar 22, 2026 9 min read

How I Structure Projects: Vertical Slices Over Layers

Mar 22, 2026

Most projects I’ve worked on organize code by what it is. Controllers in one place, models in another, services somewhere else. It’s the default, and it makes sense on the surface — things of the same kind live together.

The problem shows up when you’re trying to understand or change a feature. If I want to follow how an invoice gets created and emailed, I’m opening InvoiceController, then InvoiceService, then InvoiceRepository, then InvoiceMail, then back to an event listener in a completely different folder. The code for one feature is scattered across the project, and navigating it means holding a map in your head of where each piece lives.

I find that harder to work with than it needs to be. What I actually want is to open a folder and find everything relevant to that feature in one place. That’s the premise behind vertical slice architecture — instead of grouping by technical layer, you group by feature. The structure of the codebase reflects what the application does, not how it’s built.

What this looks like in Laravel

In a Laravel project, I nest under app/Domain/, then the feature name, then subfolders once there are enough files of a given kind to warrant splitting them out.

app/
  Domain/
    Invoicing/
      Controllers/
        InvoiceController.php
      Requests/
        CreateInvoiceRequest.php
        UpdateInvoiceRequest.php
      Jobs/
        SendInvoiceJob.php
      Mail/
        InvoiceMail.php
      InvoiceService.php
    Payments/
      Controllers/
        PaymentController.php
      Requests/
        ProcessPaymentRequest.php
      PaymentService.php
      RefundService.php
    Auth/
      Controllers/
        LoginController.php
        ResetPasswordController.php
      Requests/
        ForgotPasswordRequest.php

The subfolders aren’t pre-emptive. A small feature might just have InvoiceController.php, CreateInvoiceRequest.php, and InvoiceService.php sitting flat in Invoicing/. The grouping grows in response to the files that actually exist, not a template of what a feature is supposed to look like.

Controllers, requests, services

Within each slice I keep the same internal shape: a thin controller, a request class for validation, and a service that does the actual work.

The controller’s job is to accept a request and return a response. That’s it.

// app/Domain/Invoicing/Controllers/InvoiceController.php

class InvoiceController extends Controller
{
    public function __construct(
        private readonly InvoiceService $invoices,
    ) {}

    public function store(CreateInvoiceRequest $request)
    {
        $invoice = $this->invoices->create($request->validated());

        return response()->json($invoice, 201);
    }
}

The FormRequest class handles validation and authorization, so by the time the controller runs, both are already done.

// app/Domain/Invoicing/Requests/CreateInvoiceRequest.php

class CreateInvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'client_id'                => ['required', 'exists:clients,id'],
            'line_items'               => ['required', 'array', 'min:1'],
            'line_items.*.description' => ['required', 'string'],
            'line_items.*.amount'      => ['required', 'numeric', 'min:0'],
            'due_at'                   => ['required', 'date', 'after:today'],
        ];
    }

    public function authorize(): bool
    {
        return $this->user()->can('create', Invoice::class);
    }
}

The service is a plain PHP class that does the actual work.

// app/Domain/Invoicing/InvoiceService.php

class InvoiceService
{
    public function create(array $data): Invoice
    {
        $invoice = Invoice::create([
            'client_id' => $data['client_id'],
            'due_at'    => $data['due_at'],
            'status'    => 'draft',
        ]);

        foreach ($data['line_items'] as $item) {
            $invoice->lineItems()->create($item);
        }

        $invoice->refresh();

        SendInvoiceJob::dispatch($invoice);

        return $invoice;
    }
}

Eloquent is called directly in the service. There’s no repository layer between it and the database.

On abstractions

The repository pattern exists for two good reasons: it lets you swap out storage implementations, and it gives you a seam to mock in tests. Both are legitimate. But I don’t end up writing many tests — not because I think testing is unimportant, but because time and project conditions mean coverage is usually thin. And I’m not realistically going to swap Eloquent for something else. So if I add a repository layer, I’m paying the cost of the indirection without collecting either of the benefits.

I’ve worked in codebases where every model had an interface, an implementation, and a service wrapping both — and none of it had tests. It was abstraction for the appearance of good architecture, not for any practical reason. Reading through it took effort that should have gone into understanding the actual problem.

So I don’t add a layer unless it’s solving something real. Interfaces earn their place when there are two actual implementations, or when a dependency crosses a meaningful boundary — a third-party service, an external API, something where the concrete detail genuinely shouldn’t leak into the caller.

// Two real implementations — the interface earns its place
interface DocumentStorage
{
    public function put(string $path, string $contents): void;
    public function get(string $path): string;
}

class S3DocumentStorage implements DocumentStorage { ... }
class LocalDocumentStorage implements DocumentStorage { ... }
// One implementation, no tests — the interface adds nothing
interface InvoiceRepositoryInterface
{
    public function create(array $data): Invoice;
}

class EloquentInvoiceRepository implements InvoiceRepositoryInterface
{
    public function create(array $data): Invoice
    {
        return Invoice::create($data);
    }
}

The second example is wrapping Eloquent in a class, to wrap it in an interface, so that you could theoretically swap it out later. It’s two extra files and one more layer to read through on every data operation, for a future that never arrives.

The structure changes per language

The folder layout above is specific to Laravel. The underlying idea — keep feature code together — applies elsewhere, but what it looks like depends on the language and framework.

Some frameworks don’t give you much room to choose. Next.js ties folder structure to routing, so you largely put files where the framework expects them. In those cases I just follow the framework. Trying to impose a different organizational structure on top of a convention-driven one usually creates more friction than it removes. The idiomatic way is the right way.

In Go, there are no classes. The unit of organization is the package, and Go has its own conventions around naming and visibility.

internal/
  invoicing/
    handler.go
    service.go
    model.go
  payments/
    handler.go
    service.go
    model.go

The files and the naming shift to match the language. The goal in each case is the same: when I’m working on invoicing, I want to be in one place. The structure serves that, not the other way around.

Where shared code lives

Organizing by feature doesn’t mean every piece of code belongs to exactly one feature. Models, base classes, shared utilities — these live at the app root. What belongs inside a slice is the code that only exists because of that feature: the controller, the request, the service, the jobs, the mail. Cross-cutting concerns like authentication middleware and error handling stay global. A slice doesn’t need its own version of those.

The real limitations

This structure makes some things harder, and it’s worth being honest about that.

Shared logic is the most common problem. If several features need the same behavior, it has to live somewhere outside any single slice. Sometimes that’s the right call and it becomes a shared service. Sometimes you end up with similar code in two places that gradually drifts apart until someone realizes they’ve been maintaining two versions of the same thing.

Larger teams feel this more acutely. In a layered structure, related code is grouped by type — all controllers together, all services together — so when two people solve the same problem in slightly different ways, the duplication is visible. In a feature-based structure you have to be more deliberate about spotting overlap, because the similar code isn’t sitting next to each other.

Onboarding takes a different shape. In a layered project, you can find all the controllers without knowing anything about the domain. Here, you need enough context to know you’re looking for something invoice-related before you end up in the right folder. That’s a real cost for someone new to the codebase.

Testing is the most direct tradeoff from the decision to skip abstractions. Calling Eloquent directly in a service makes mocking harder. When I do write tests, I run them against a real database with factory data — which works, but it’s slower and requires more setup than mocking a repository would. I’ve made a deliberate choice there, and it has a cost.

Why I keep using it

None of those limitations are dealbreakers for the kind of projects I work on. And the thing it does well — keeping all the code for a feature in one place — turns out to matter a lot in practice. A change to invoicing means opening Domain/Invoicing/. I’m not building a mental map of which layer holds which piece of which feature, I’m just working on the feature.

The controllers, request classes, and services are useful regardless of how the project is organized. What the feature grouping adds is that they’re findable without archaeology. The code tells you where it lives by what it does, not by what kind of thing it is.

That’s the part that stuck.

Share