Daniel Howells

The Monorepo as Product

2026-01-05

Materia is a monorepo with 33 packages and 5 apps. That sounds like overkill until you understand what each package does and why it exists. The structure isn't complexity for its own sake — it's the architecture made visible.

The split: apps/ has deployable things — the main web app, a material tagging tool, warehouse management, a brand portal, Storybook. packages/ has shared code — @materia/db for the Drizzle schema, @materia/trpc for the API layer, @materia/ui for the design system, @materia/env for environment validation. Then domain packages: @materia/projects, @materia/notes, @materia/saves, @materia/search, @materia/cart.

The key decision was just-in-time packages. No build step for packages. They export source TypeScript directly via exports in package.json, and the consuming app's bundler handles the compilation. This means changes to a shared package are immediately reflected in the dev server — no build, no watch process, no stale output. The DX improvement is significant.

The database package is the centre of gravity. @materia/db defines every table, enum, index, and relation. It exports the schema types that flow into tRPC, which flow into React Query, which flow into components. Change a column here and the type system propagates the change everywhere. The schema file is over a thousand lines — products with JSONB attributes, vector embeddings with HNSW indexes, hierarchical categories with ltree paths, quantised colour data with dual HSL and OKLab models. It's the most important file in the repository.

I use Turborepo for task orchestration but only lightly. pnpm dev starts everything except Storybook. pnpm build builds everything in dependency order. pnpm test runs all tests. The turbo.json config is small because most of the complexity lives in the packages themselves, not in the task graph.

The environment package deserves mention. @materia/env validates all environment variables at boot using Zod schemas. Server-only vars and client-safe vars are separated. No code anywhere reads process.env directly — it imports from the env package. This catches misconfiguration immediately instead of at runtime when some edge case hits an undefined variable.

I wouldn't use a monorepo for everything. A single app with no shared code doesn't need one. But when you have multiple apps consuming the same database schema, the same UI components, and the same API types, a monorepo eliminates an entire class of coordination problems. The packages aren't boundaries between teams — they're boundaries between concerns. And those boundaries are the architecture.