The same monolith laid out by layer and by feature

Take a small monolith with three capabilities: orders, billing and users. Laid out by layer, the top-level folders follow technical roles:

  • controllers/: OrderController, InvoiceController, UserController
  • services/: OrderService, BillingService, UserService
  • repositories/: OrderRepository, InvoiceRepository, UserRepository
  • models/: Order, Invoice, User

Laid out by feature, the top-level folders follow business capabilities:

  • orders/: handler, order rules, OrderRepository, Order
  • billing/: handler, invoicing logic, InvoiceRepository, Invoice
  • users/: handler, user logic, UserRepository, User

The layered tree tells you what kind of code a file is. The feature tree tells you what the code is for. Adding a "cancel order" capability touches four folders in the first layout and one in the second.

Vertical slice architecture goes a step further than grouping files. Each slice owns its full path from request to storage: the entry point, the domain logic and the persistence. Because a slice owns all of that, it can choose its own internal pattern. billing/ might use a rich domain model with careful transaction handling. users/ might be thin handlers that issue queries directly. In a layered design, every feature is expected to pass through the same service and repository stack.

Moving files into feature-named folders does not make a codebase feature-packaged on its own. If orders/ still calls into billing/'s repository and reads its tables directly, the coupling is unchanged. The difference is in the dependency boundaries, not the folder names.

src/
  controllers/   OrderController  InvoiceController  UserController
  services/      OrderService     BillingService     UserService
  repositories/  OrderRepository  InvoiceRepository  UserRepository
  models/        Order            Invoice            User
src/
  orders/
    CancelOrderHandler      # entry point for the use case
    OrderRules              # domain logic owned by orders
    OrderRepository         # persistence for orders' own tables
    Order
  billing/
    IssueInvoiceHandler
    Invoice                 # rich domain model with its own invariants
    InvoiceRepository       # explicit transaction handling lives here
    BillingApi              # the only surface other slices may call
  users/
    GetUserHandler          # thin handler that queries storage directly,
                            # no service or repository layer

The same orders, billing and users monolith laid out by layer and by feature. Each feature slice owns its handler, logic and persistence and can use its own internal pattern. The last lines show that the dependency boundary, not the folder name, decides whether the code is feature-packaged.

How package by feature works

Package by feature changes the unit of organisation from "technical role" to "business capability". It rests on three mechanisms working together:

  1. Grouping. Every file needed to deliver one capability (entry points, domain logic, persistence, data types) goes in that capability's folder, package or module. The layers still exist, but inside each feature rather than across the whole codebase.
  2. Hiding. Each feature uses its language's visibility rules to keep most of its contents private. Entities, repositories and helper classes are not reachable from other features.
  3. A narrow public surface. Each feature exposes a small API, such as a facade class, a handful of commands and queries, or published events. Other features depend only on that surface and refer to each other's data by ID.

Dependencies then run between features through their public APIs, and within a feature from the entry point down to storage. A change to how orders are stored stays inside orders/ as long as the public API does not change. The sections below cover each mechanism in detail: why grouping reduces change scope, how visibility and CI enforce the boundary, and how shared code fits in.

Why code that changes together should live together

Directory structure determines where a change lands. Most feature work cuts across several technical layers at once, so a structure organised by layer spreads a single change around the tree. A structure organised by feature keeps it in one place.

Consider adding a percentage discount to orders. The request needs a new discountCode field, the controller validates it, the pricing logic applies it, the order entity stores the discounted total, and the repository persists it.

Change step Package by layer Package by feature
Request field dto/ orders/
Validation controllers/ orders/
Pricing rule services/ orders/
New column on entity entities/ orders/
Persistence repositories/ orders/
Top-level directories touched 5 1

The count is a proxy for coordination cost. In the layered tree, each of those five directories also holds code for customers, invoices and shipping. To find the order code, the author has to search across all five. The reviewer has to rebuild the change from a diff scattered through unrelated files. Two teams working on different features in the same week edit the same directories, which increases the chance of merge conflicts and widens the scope of review.

In the feature layout, the diff is limited to orders/. A reviewer can read it as a single unit and ask whether the change is correct for orders, without first filtering out unrelated code. Ownership follows the same boundary. One team owns orders/, a code-owners rule can map that directory to that team, and approvals go to the people who understand the domain.

The time saved comes from smaller search space, shorter reviews and fewer cross-team handoffs. It does not come from writing less code.

How package by feature saves engineering time

The savings show up in a few specific activities:

  • Finding code. An engineer starting on a billing change opens billing/ and has the whole capability in front of them. In a layered tree they reconstruct it from several directories that also hold unrelated code.
  • Reviewing changes. A diff confined to one feature folder can be reviewed as one coherent change by the team that owns it, instead of being routed to whoever owns each shared layer directory.
  • Avoiding merge conflicts. Teams working on different features edit different folders, so concurrent work collides less often than when everyone edits the same services/ and repositories/ directories.
  • Changing safely. When a feature's internals are private, a refactor inside it cannot break other features at compile time, so the engineer can skip the search for hidden callers.
  • Onboarding. A new engineer can learn one feature end to end without first learning the whole layered tree.
  • Deleting code. Removing a retired capability means removing a folder and its public API callers, not hunting for its pieces in every layer.

To see how this adds up, here is an illustrative estimate with assumed inputs, not a measured result. Assume a team ships 40 order-related changes per quarter. Assume the layered layout adds 10 minutes per change to locate the relevant files across directories and 10 minutes per change for the reviewer to piece the diff together. That is 40 × 20 = 800 minutes, roughly 13 engineer-hours per quarter, before counting merge-conflict resolution. Your own numbers will differ. The useful exercise is to plug in your team's change volume and an honest guess at the per-change overhead, then compare that against the one-off cost of migrating described later.

How feature boundaries are enforced: visibility, public APIs and automated checks

Folder structure on its own enforces nothing. A directory named billing/ stops no one from importing a class out of it. Boundaries hold only when the compiler, the build or CI rejects code that crosses them.

Language visibility

Most languages can hide a feature's internals. In Java, a class without a modifier is package-private, so a feature in a single package can keep its repositories and entities invisible to other packages. Kotlin's internal and C#'s internal work at the module or assembly level, so they only protect a feature that is built as its own module or project. In Go, lowercase identifiers are unexported, and an internal/ directory restricts who can import a package. In TypeScript and Node.js, a module exposes only what it exports, and a package's exports map can block deep imports into its files.

Packaging by layer works against all of these rules. When OrderController sits in controllers and OrderRepository sits in repositories, each must be public for the other to use it. Once everything is public, nothing is internal.

Narrow APIs between features

A feature should expose a small facade, such as OrderService.placeOrder(customerId, items) returning an OrderId, and keep everything else private. Other features talk to it through that API, through in-process events or by holding its IDs. They never read its tables or reach into its entities. Shared tables create coupling that no visibility rule can detect.

Automated checks

Where the language cannot enforce a boundary, enforce it in CI. Options include architecture tests that assert "no class outside orders depends on orders.internal", import-linting rules that forbid certain paths, and build-module dependency declarations that fail compilation when a forbidden dependency is added.

// orders/OrderId.java: public, other features may hold this ID
package com.example.shop.orders;
import java.util.UUID;
public record OrderId(UUID value) {}

// orders/OrderService.java: the feature's only public entry point
package com.example.shop.orders;
import java.util.List;
import java.util.UUID;
public class OrderService {
    private final OrderRepository repository;
    // Package-private constructor: wiring happens inside this package
    OrderService(OrderRepository repository) { this.repository = repository; }

    public OrderId placeOrder(UUID customerId, List<String> skus) {
        Order order = new Order(new OrderId(UUID.randomUUID()), customerId, List.copyOf(skus));
        repository.save(order);
        return order.id();
    }
}

// orders/Order.java: no modifier, so invisible outside the package
package com.example.shop.orders;
import java.util.List;
import java.util.UUID;
record Order(OrderId id, UUID customerId, List<String> skus) {}

// orders/OrderRepository.java: also package-private
package com.example.shop.orders;
interface OrderRepository { void save(Order order); }

// billing/InvoiceService.java: another feature
package com.example.shop.billing;
import com.example.shop.orders.OrderId;    // compiles: public type
// import com.example.shop.orders.Order;   // compile error: Order is not public
public class InvoiceService {
    public void invoice(OrderId orderId) { /* refers to the order by ID only */ }
}

A Java orders feature keeps its entity and repository package-private and exposes only OrderService and OrderId, so the compiler stops the billing feature from reaching its internals.

Shared code, duplication and cross-feature queries

Boundaries only hold if the shared folder stays small. Split it by purpose. A shared kernel holds value types that every feature means the same thing by, such as Money, CustomerId or Clock. Infrastructure adapters like the database client, message publisher and HTTP plumbing go in a separate platform package with no business rules in it. Cross-cutting concerns such as logging, auth and transactions belong in that platform package too, usually applied through middleware or decorators so slices don't call them directly.

To keep shared from becoming a hidden service layer, give it a named owner and an admission rule. Code moves in only when at least two features use it, it has no knowledge of any feature, and its owner agrees to review changes to it. Anything that mentions "order" or "invoice" fails the rule.

Duplication is cheaper than the wrong abstraction. If you extract similar code early, two features that later diverge end up tied to one function full of flags. If you leave copies too long, bug fixes land in one copy and not the other. A workable rule is to extract once the copies have changed together a few times, not when they first look alike.

Dashboards that join orders, payments and inventory should not reach into each slice's tables. Put them in a dedicated reporting module backed by a read model, built either from published events or from SQL views that each feature owns and exposes.

Inside each slice, keep the same few names, for example api, domain, persistence and internal. Reviewers then know where to look in any feature.

When package by layer is the better choice and when to avoid feature slices

Layering earns its place when features barely differ from one another. Consider a service that exposes a dozen resources, each with the same create, read, update and delete flow, the same validation style and the same table-per-resource mapping. Slicing it by feature produces a dozen nearly identical folders, each holding a controller, a service and a repository that differ only in names. Grouping by layer keeps that repetition visible in one place, which makes shared fixes such as pagination or error mapping easier to apply consistently.

Tooling is another reason. Many frameworks, tutorials and code generators assume controllers/, services/ and repositories/ at the top level. Scaffolding commands, documentation examples and new hires all arrive with that expectation. Fighting it in a small service costs onboarding time and buys little.

The cost of guessing boundaries too early

Feature slices only help if the boundaries match how the business actually changes. Early in a product's life those boundaries are usually guesses. When a guess is wrong, slices keep importing each other's internals, shared entities get pulled into a common package, and every change crosses three folders anyway. That is worse than clean layers, because the structure now claims an independence that does not exist. Moving code between slices later is also harder than splitting a layer.

Package by layer suits small teams, short-lived services and CRUD-heavy APIs. Package by feature pays off for larger codebases with several teams, distinct domain rules per area, and a likely future split into modules or services.

Who should use package by feature

Package by feature is a good fit for:

  • Monoliths worked on by more than one team. Feature folders map directly to team ownership, so code-owner rules, review routing and on-call responsibility all follow the same boundary.
  • Codebases with real domain logic. When billing, ordering and fulfilment each have their own rules and workflows, grouping each capability's code keeps those rules in one place.
  • Long-lived systems. The upfront cost of defining boundaries is repaid over years of changes, not weeks.
  • Teams considering a modular monolith or a later service split. Feature packages with enforced boundaries are the starting point for both.
  • Engineers who keep making changes that touch many layer directories at once. That pattern is the most direct signal that the current layout does not match how the code changes.

It is a weaker fit for small CRUD services, prototypes whose domain is still unclear, and projects where a framework's layer-based conventions provide most of the structure. In those cases, package by layer, or a layered layout with a plan to revisit, is usually the cheaper choice.

Alternatives to package by layer and package by feature

These two layouts are not the only options, and several alternatives combine with them rather than replace them:

  • Hexagonal architecture (ports and adapters). The domain sits at the centre and defines interfaces (ports). Adapters for HTTP, databases and messaging implement them at the edges. It describes dependency direction rather than top-level folders, so it can be applied inside each feature package or across a whole layered codebase.
  • Clean architecture and onion architecture. Related ideas that arrange code in concentric rings with dependencies pointing inward toward the domain. Applied at the top level, they produce a layer-style layout. Applied per feature, they give each slice an internal structure.
  • Package by component. A middle ground in which each component bundles business logic and persistence behind a public interface, while the web or delivery layer stays separate. It keeps a shared entry-point layer but still hides each capability's internals.
  • Hybrid layouts. A common pragmatic choice is feature folders at the top level with consistent layer sub-folders inside each one, as in the api, domain, persistence naming mentioned earlier.
  • Separate build modules or projects per feature. Rather than packages in one module, each feature becomes its own module in the build. This makes boundaries enforceable through module-level visibility and declared dependencies, at the cost of more build configuration.
  • Separate services. Splitting capabilities into independently deployed services gives the strongest boundary, but adds network calls, distributed failure modes and operational overhead. For most teams it is a later step after a modular monolith, not an alternative to organising the monolith well.

The choice among these is mostly about how strictly boundaries are enforced and at what granularity. Package by layer and package by feature describe the top-level grouping. The others describe dependency direction or deployment units, and usually sit alongside one of the two.

Migrating a layered codebase to feature packages incrementally

Start with a reason you can name: a feature whose changes keep touching six directories, merge conflicts between teams in shared service classes, or a planned extraction that is blocked because nobody can say what "billing" includes. Preference alone does not justify the move. The cost is real: hours of moving files and fixing imports, a burst of churn that makes blame output noisier, and conflicts for every open branch that touches moved files.

A sequence that limits risk

  1. Build new features as slices. This costs nothing extra and proves the layout before you move existing code.
  2. Move one vertical at a time. Pick a feature with few inbound dependencies. For invoicing, the change looks like this:
    • Before: controllers/InvoiceController, services/InvoiceService, repositories/InvoiceRepository, models/Invoice
    • After: invoicing/InvoiceController, invoicing/InvoiceService, invoicing/InvoiceRepository, invoicing/Invoice
  3. Add the boundary check when the slice lands. Narrow visibility and add the automated rule in the same change. A boundary added months later has usually been crossed already.
  4. Extract shared types deliberately. If Money is used by three features, move it to a shared module on purpose. Do not let it drift into one slice and get imported from there.

Keeping history and branches usable

Make each move a commit that only moves files, with no edits. Rename detection works best on unchanged content, and git log --follow can trace a file across the move. Announce each move ahead of time and land it when few branches touch that feature, so other teams rebase once instead of repeatedly.

#!/usr/bin/env bash
set -euo pipefail

SRC=src/main/java/com/example/app

mkdir -p "$SRC/invoicing"
git mv "$SRC/controllers/InvoiceController.java" "$SRC/invoicing/"
git mv "$SRC/services/InvoiceService.java"       "$SRC/invoicing/"
git mv "$SRC/repositories/InvoiceRepository.java" "$SRC/invoicing/"
git mv "$SRC/models/Invoice.java"                "$SRC/invoicing/"

git diff --cached -M --name-status
git commit -m "Move invoicing classes into invoicing package (move only)"

sed -i -E 's/^package com\.example\.app\.(controllers|services|repositories|models);/package com.example.app.invoicing;/' \
  "$SRC"/invoicing/*.java

grep -rlE 'import com\.example\.app\.(controllers|services|repositories|models)\.Invoice' src || true

Moving the invoicing slice with a move-only commit followed by a separate package and import fix, so rename detection and git log --follow keep the history traceable.

Decision checklist and the path to a modular monolith

Once the moves have landed, decide where the codebase should end up, and whether the move is worth it at all.

Factor Favours package by layer Favours package by feature
Codebase size Small enough for one person to hold in their head Large enough that people navigate by search
Teams and ownership One team owns everything Several teams, each owning distinct capabilities
Domain complexity Few rules, little behaviour per entity Rich rules that differ between capabilities
CRUD share Mostly forms over tables Workflows, state machines, calculations
Framework conventions Framework expects controllers/, models/ and similar directories Framework is agnostic or supports module directories

If most rows land on the right, feature packages pay for themselves. Add enforced boundaries, such as restricted visibility, one public entry point per feature, and a build check that rejects imports of internal code, and you have what is usually called a modular monolith.

The modular monolith is useful even if nothing is ever split out. It also makes splitting cheaper later. A module that is reached only through its public API, and owns its own tables, already has the seams a service needs: a contract, a data boundary and a known set of callers. Extraction then becomes mostly a transport change. That option is a side effect, not the aim. Many modular monoliths stay single deployables because one deploy is simpler to operate.

Guidance on which layout to use:

  • Stay layered for small, CRUD-heavy apps built on convention-driven frameworks.
  • Move to features when several teams keep colliding in the same layer directories.
  • Enforce boundaries once features exist. Unchecked boundaries erode with each release.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to package by feature vs package by layer, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.