DEV Community

CodeNameGrant
CodeNameGrant Subscriber

Posted on

Domain-First Nx Monorepos: Using `packages/` to Make Ownership and Boundaries Obvious

Where should this go? Which apps are using this library?

Those two questions are plaguing my team as our monorepo grows. Not because we don’t know Nx, TypeScript, or React—but because our folder structure doesn’t clearly communicate ownership.

We started with a pretty standard setup:

  • apps/<app>
  • libs/<domain>/<lib>
  • libs/shared/<lib>

That structure was a great starting point (this was our first real dive into monorepo architecture). It was simple, it scaled enough, and it let us move fast early on. But we’re now at ~40 libraries and growing, and it’s becoming less obvious:

  • where a new library should live, and
  • whether a library is truly domain-specific or actually shared.

This post explains why we plan to migrate to a domain-first packages/ layout. The main goal being domain clarity (and making module boundaries easier to enforce consistently).


Flat libs/ worked… until it didn’t

The hardest part of a growing monorepo isn’t the tooling—it’s the ambiguity. When everything lives side-by-side in libs/, the folder structure stops answering basic questions, like:

  • Who owns this library?
  • Which app is it actually supporting?
  • Is this genuinely shared, or did someone just reuse it once?
  • If I’m adding a new thing, where’s the “obvious” place to put it?

So instead of the repo guiding you, you end up relying on tribal knowledge (or you run nx graph and play detective).

A concrete example (the kind that trips new people up)

We have a contract-web app and a library with a name that sounds like it belongs to it—something like data-access-contract. If you’re new to the workspace, you’d make a totally reasonable assumption:

“Cool, contract-web uses data-access-contract.”

Except… it doesn’t. company-web is the one consuming it.

Nothing about a flat folder structure makes that mismatch obvious. And this is where the time goes. Not in builds—mostly in the little moments:

  • onboarding takes longer than it should,
  • “where should this live?” becomes a recurring message thread,
  • libraries slowly drift into whatever location was convenient at the time,
  • and cross-domain dependencies happen by accident.

At this point, our bottleneck isn’t build speed—it’s how quickly someone can answer: “Where does this code belong?”


Implementing packages/: make the repo explain itself

What we want is simple: we want the repo structure to answer the questions we keep asking.

  • What domain does this code belong to?
  • Is it shared, or is it local to one app?
  • Where does a new feature go?
  • If I’m trying to understand a domain, where do I start?

That’s what’s driving the move to a domain-first layout:

  • packages/<domain>/app/...
  • packages/<domain>/libs/...
  • packages/shared/libs/...

Why packages/?

“Packages” isn’t a new monorepo law; it’s just a common convention that communicates:

“These are grouped things that belong together under a single workspace.”

Nx supports many layouts. We’re choosing this one because it makes domain boundaries visible, which reduces cognitive load.

Module boundaries: make the “right” thing the easy thing

We’re also using this restructure to make module boundaries easier to reason about and enforce. Nx boundaries are tag-based, so the plan is:

  • tag everything in a domain with domain:<domain>
  • tag shared libs with domain:shared

Then enforce a few simple rules:

  • A domain app can import:
    • libraries from its own domain, and
    • libraries from domain:shared
  • Domain libraries should not import from other domains
  • shared libraries should not import from domain libraries

The real win here isn’t the lint rule itself—we can write those today. The win is that once the code is physically grouped by domain, those rules stop feeling like “extra process” and start feeling like the natural shape of the repo.

In other words: developers don’t need deep context to do the right thing, and when someone does the wrong thing, Nx catches it early.


High-level migration plan (draft)

Use the @nx/workspace:move generator to do the heavy lifting when moving projects in an Nx Workspace

  • [ ] Create the new /packages skeleton (/packages/<domain> and /packages/shared)

  • [ ] Move each app one at a time from apps/<app>packages/<domain>/app

    • [ ] Tag each project with domain:<domain>
    • [ ] Verify it’s visible in nx graph
  • [ ] Move the libs that are already shared from libs/shared/*packages/shared/libs/*

    • [ ] Tag each project with domain:shared
    • [ ] Rename projects only when it’s an easy win during the move (otherwise leave names alone for now)
  • [ ] Move “definitely domain-local” libs (one domain at a time)

    • [ ] For a given domain, move only the libs we’re confident are not shared
    • [ ] Tag each project with domain:<domain>
  • [ ] Handle the “maybe shared” libs (after the obvious stuff is moved)

    • [ ] For each “maybe shared” lib: keep it domain-local or intentionally promote it to shared
    • [ ] Tag each project appropriately
  • [ ] Clean-up pass: rename projects that still need it

    • [ ] Standardize remaining project names to match the new conventions

If this works the way we expect, the best sign will be that people stop asking where things should go—because the answer will be obvious.


Bonus benefits

None of this is guaranteed for every Nx monorepo. These benefits are mostly a product of how our repo works today. We also may not implement all of these immediately—think of them as “now possible / now easier” rather than promised outcomes.

1) Simpler GraphQL Code Generator workflow

In our environment, GraphQL codegen is currently very flexible (flags for different services and libs), but that flexibility comes with cognitive overhead and occasional noisy regen. With packages/<domain>/*, we can shift the default workflow to: “I’m working on this app → run codegen for this app/domain”, and have it target:

  • packages/<domain>/libs/*, plus
  • packages/shared/libs/*

That trades some granularity for a workflow that’s harder to mess up.

2) Apollo type policies become easier to wire consistently

Right now, type policies live in feature libs and apps manually import them. That’s workable, but easy to forget as features move or get reused. Once the repo is domain-grouped, it becomes straightforward to generate an app-level “policy aggregator” (per domain app) that automatically pulls in:

  • domain policies exported from all domain-local libs, plus
  • shared policies

This won’t magically solve all policy design problems, but it reduces the “remember to wire it up in every app” overhead.

3) Generators become much more predictable (domain + type = obvious destination)

With a flat libs/ directory, a generator can create a library, but it can’t reliably answer: which app/domain should this belong to? That decision often depends on local context.

After the migration, we can write (or customize) generators that take inputs like:

  • domain (companycontract, etc.)
  • type (featureuidata-access, …)
  • name (invoicesteams, …) …and always place the output in a predictable location, e.g.:
  • packages/company/libs/feature-invoices

This is one of those quality-of-life improvements that compounds over time.

4) Possible future win: more predictable implicit libraries

This one is more of an idea than a guaranteed outcome, but a domain-first folder structure should make Nx’s “implicit libraries” approach a lot more predictable, because the filesystem starts encoding intent (domain + shared) instead of everything living in one flat space.

If we go down this route later, the hope is we can model impacts with fewer manual touch points—similar to the approach described here: Nx Implicit Libraries: The Hidden Gem


Closing

Ultimately, we want a monorepo that’s easy to navigate, hard to misuse, and doesn’t require tribal knowledge to be productive.

Top comments (0)