A layered architecture for TypeScript monorepos with Effect
Building maintainable systems by taming complexityThe problem
I recently helped an early-stage startup whose TypeScript monorepo had grown into an unmaintainable mess. Their monorepo had evolved into a web of circular dependencies where packages imported from each other in confusing loops. The team had created dozens of packages with no clear hierarchy. Worst of all, their business logic was tightly coupled to both data access code and workflow orchestration, meaning any change to how they stored data would ripple through their entire system.
The engineering team was spending more time untangling dependencies than shipping features. New developers took weeks to understand the codebase structure, and even experienced team members were afraid to make changes due to unexpected side effects.
Solution
I proposed adopting a layered, volatility-based approach to organizing monorepo packages based on a framework from Righting Software by Juval Löwy.
This has an additional benefit: Effect’s layer system is powerful but open-ended. By adopting this approach, we add structure to usage of this flexible toolkit.
Philosophy
The main principle behind this proposed approach is encapsulating volatility (the tendency of a system to change). This stands in stark contrast to traditional domain-driven design, which organizes code around “what the system does” rather than “how it changes”.
Domain-driven design creates silos by content, but it mixes orchestration, business rules and data access within these silos, thus creating brittle architectures where a change to one aspect ripples throughout the system.
Organizing a system into layers that encapsulate different rates of change creates a natural hierarchy where volatility decreases as you move down the layers, ensuring that the modules with the most dependencies are also the most stable. The most volatile modules (UI) sit at the top, while the most stable modules (data stores) anchor the bottom. Changes to any single layer should be properly encapsulated and require minimal upstream or downstream changes.
Layers
These layers correspond to “who”, “what”, “how”, and “where”, providing a structured approach where each layer encapsulates its volatilities from the layers above and below.
Layer | Responsibility | Description |
---|---|---|
Client | “who” invokes the system | All entry‐points/user interfaces (human, system-to-system, etc) |
Manager | “what” steps to run | Orchestration of what the system must do. They decide what steps to perform and in what order |
Engine | “how” to do a step | How the system performs business activities, e.g. algorithms, calculations, etc |
ResourceAccess | “how” to get data | Abstraction over persistent stores and external API services |
Resource | “where” data is stored | The actual persistent stores and endpoints |
Utility | cross-cutting | Domain agnostic services such as logging, security, diagnostics, message bus, etc available to every layer |
By mapping every part of your system onto a small, fixed set of roles, you get:
- Clear semantic boundaries: Each layer answers a single question (“who”, “what”, “how”, “where”), so it has one responsibility and one reason to change.
- Rigid, downward-only dependencies: No layer can call up or sideways, only “down”. This prevents unintended coupling.
- By only allowing calls “down” one layer (plus into Utilities), we keep our dependency graph acyclic.
- We can divide our
libs
into these sub-folders, e.g.libs/managers
,libs/engines
and enforce boundaries programmatically (see ‣)
- Encapsulation of volatility: Business rule changes live in Engines, data access changes live in ResourceAccess, orchestration changes live in Managers. Changes ripple no farther than their layer (and of course any upstream layers that call the change).
- Predictable, uniform structure: With only six component types, we reduce cognitive load with shared terminology.
Rules
In this system, each layer may only call “down” and usually only to one level below (or into Utilities). No layer may call up or sideways. Concretely:
- Client may call Managers
- Manager may call Engines and ResourceAccess
- Engine may call ResourceAccess
- ResourceAccess may call Resources
- Resource doesn’t call any other layer
- Utility called by any layer, but calls no one
Naming
All names use PascalCase and are two-part compounds (Prefix+Suffix). This helps developers understand at a glance what layers are being used in a file.
- Manager
- Suffix = “Manager”
- Prefix = a noun describing the use-case or orchestration volatility (“what” the workflow is about).
- Examples: OrderManager, InvoiceManager, SessionManager.
- Engine
- Suffix = “Engine”
- Prefix = a noun or gerund form of verb (verb+“ing”) that describes the encapsulated activity volatility (“how” it does its work).
- Examples: CalculatingEngine, MetricEngine, ValidationEngine.
- ResourceAccess
- Suffix = “Access”
- Prefix = the resource noun (the type of data or external system) being encapsulated.
- Examples: CustomerAccess, ProductAccess, CalendarAccess.
- For ResourceAccess methods, you should never surface CRUD or I/O-style verbs. Those names betray the underlying storage and lock you into that model. Instead, expose only atomic business verbs that map to real business operations (e.g.
creditAccount()
,debitAccount()
,placeOrder()
,cancelReservation()
). By keeping the API purely in business terms, you insulate the rest of the system from any future change in how or where the data is stored.
- Resource
- Suffix = “Resource”
- Prefix = Name of the actual resource type
- Examples: PostgresResource, FileSystemResource, RedisResource
- Utilities
- Name with a descriptive noun and add “Util” as a suffix
- Examples: LoggingUtil, EventBusUtil
Example
Here’s a high-level mapping of components for an AI-driven todo app:
- Managers
- DailyAgendaManager: Gathers inputs (tasks, calendar events) and produces the morning brief
- Engines
- BriefingEngine: Takes an array of events and turns them into a schedule broken up by hour
- NLPParsingEngine: Parses free-form text or email content into structured tasks/projects
- ResourceAccess
- TaskAccess: Read/write operations for tasks
- CalendarAccess: Read/write events to calendar
- PreferencesAccess: Load user settings (e.g. notification rules)
- Resources
- PostgresResource: SDK for querying/mutating
- GoogleCalendarResource: External HTTP API SDK
Effect implementation
We will keep relevant types that will be used across the layers in a shared types package (in the Util layer) to prevent cyclic dependencies.
// shared
type Task = //...
type CalendarEvent = //...
The types for other layers can usually stay encapsulated within their packages since they shouldn’t leak to upstream layers without the actual implementation also being imported.
// Engine
export type BriefingEngine = {
generateBrief: (params: {
tasks: Task[];
todaysEvents: CalendarEvent[];
}) => Effect.Effect<string, BriefingEngineError>;
};
// ResourceAccess
export type CalendarAccess = {
listTodaysEvents: () => Effect.Effect<CalendarEvent[], CalendarAccessError>;
};
export type TaskAccess = {
listActiveTasks: (query?: string) => Effect.Effect<Task[], TaskAccessError>;
};
// Resource
export type GoogleCalendarResource = {
use: <T>(
fn: (client: calendar_v3.Calendar) => Promise<T>
) => Effect.Effect<T, GoogleCalendarError, never>;
};
To use these abstract types in Effect, we will define them with Context.Tag
. We will suffix these with Repo
(referring to the Repository pattern) to easily tell that these are abstract types. This is mostly helpful in the early stages when developers might confuse the abstract Repo with the actual implementation of the type.
export class TaskAccessRepo extends Context.Tag("TaskAccessRepo")<
TaskAccessRepo,
TaskAccess // this is the type defined above
>() {}
We define Manager orchestration logic with these abstract Repos that are unaware of specific implementation details (such as what Resource is used under-the-hood).
export const DailyAgendaManagerBlueprint = Effect.gen(function* () {
const taskAccess = yield* TaskAccessRepo;
const calendarAccess = yield* CalendarAccessRepo;
const briefingEngine = yield* BriefingEngineRepo;
const [todaysEvents, tasks] = yield* Effect.all([
calendarAccess.listTodaysEvents(),
taskAccess.listActiveTasks(),
]);
const response = yield* briefingEngine.generateBrief({
tasks,
todaysEvents,
});
return response;
});
Then we can implement the actual Resource + ResourceAccess layers. In Effect, it’s idiomatic to use the suffix Live
to differentiate live implementation from the suffix Test
, which can be dummy implementations used for unit tests, etc.
export const TaskAccessLive = Layer.effect(
TaskAccessRepo,
Effect.gen(function* () {
// underlying SDKs should be wrapped in a separate Effect for safer error-handling, but to keep this example simple, we init directly
const db = new Kysely();
return {
listTasks: () => db.selectFrom(//...
}
}),
)
export const CalendarAccessLive = Layer.effect(
CalendarAccessRepo,
Effect.gen(function* () {
const gcal = new calendar_v3.Calendar();
return {
listTodaysEvents: () => gcal.query(//...
}
}),
)
export const BriefingEngineLive = Layer.effect(
BriefingEngineRepo,
Effect.gen(function* () {
const openai = new OpenAi();
return {
generateBrief: ({ tasks, todaysEvents }) => {
const systemPrompt = `Given ${tasks} and ${todaysEvents}`// ...
return generateText({ model: openai, messages: [systemPrompt] });
}
}),
)
We inject the live implementations at runtime
export const DailyAgendaManagerLive = DailyAgendaManagerBlueprint.pipe(
Effect.provide([TaskAccessLive, CalendarAccessLive, BriefingEngineLive])
);
export const run = () => {
const res = await Effect.runPromise(DailyAgendaManager);
return res;
};
NX boundary enforcement
Nx can programmatically enforce your layer boundaries using tags and ESLint rules. Each package gets tagged by layer (layer:client
, layer:manager
, layer:engine
, etc.), then the @nx/enforce-module-boundaries
rule enforces your “downward-only” dependency constraints.
For example, you’d configure layer:manager
packages to only depend on layer:engine
, layer:resource-access
, and layer:utility
packages. The linter catches violations immediately, removing the discipline burden from code reviews and making your volatility-based architecture self-enforcing.
Results
After implementing this layered approach, the startup saw immediate improvements across all their original pain points:
Circular dependencies eliminated: The rigid “downward-only” dependency rule made circular imports impossible. Where packages previously imported from each other in confusing loops, the new structure created a clear hierarchy. Managers could only call down to Engines and ResourceAccess, Engines could only call ResourceAccess, and so on. This transformed their tangled dependency graph into a clean, directed acyclic structure.
Clear package hierarchy: Instead of dozens of packages with unclear relationships, the team now had six well-defined layer types. Every package was tagged with its layer (layer:manager
, layer:engine
, etc.), making the system’s structure immediately visible. New developers could look at any package name—like OrderManager
or ValidationEngine
—and instantly understand both its responsibility and its place in the architecture.
Decoupled business logic: The separation of concerns across layers solved their coupling problem. Business rules lived in Engines, data access patterns lived in ResourceAccess, and workflow orchestration lived in Managers. When they needed to change how data was stored, only the ResourceAccess layer required updates. When business rules changed, only the relevant Engine needed modification. Changes stopped rippling through the entire system.
Faster feature development: With clear boundaries and predictable structure, the engineering team could focus on shipping features instead of untangling dependencies. Developers knew exactly where new code belonged and could work in parallel without stepping on each other’s changes.
Improved onboarding: New developers now understood the codebase structure within days rather than weeks. The consistent naming conventions and layer responsibilities provided a mental model that applied across the entire system. Instead of memorizing dozens of package relationships, they only needed to understand six layer types and their interaction rules.
The combination of Effect’s type safety with this structural approach gave the team both compile-time guarantees and architectural discipline—transforming their monorepo from a maintenance burden into a productive development environment.
When to use this approach
This layered architecture isn’t always necessary. Here’s when the complexity is justified:
Use this approach when:
- You have 10+ packages/modules with unclear boundaries
- Multiple developers are working on the same codebase
- You’re experiencing circular dependency issues
- Changes to one part of the system frequently break unrelated parts
- New team members take more than a week to understand the codebase structure
- You’re building a system that will evolve significantly over time
Skip this approach when:
- You have a simple application with fewer than 5 modules
- You’re prototyping or building a proof-of-concept
- Your team is 1-2 developers who understand the entire codebase
- You’re building a short-lived project or one-off script
- The overhead of layer enforcement would slow down development more than help
Although there is upfront investment in structure and discipline, this architecture pays dividends over time by preventing technical debt.
Misc
Client vs Manager
Managers are reusable because you can use the same Manager and use cases across multiple Clients.
Clients are hardly ever reusable. A Client application is typically developed for a particular type of platform and market and cannot be reused.
Manager vs Engine
To distinguish Manager vs Engine, use the “sequence vs. activity” rule:
- Is the conditional about “what to do next”?
- E.g. “If the user’s account is premium, send a high-priority notification; otherwise, enqueue it.”
- That’s orchestration logic: belongs in a Manager.
- E.g. “If the user’s account is premium, send a high-priority notification; otherwise, enqueue it.”
- Is the conditional part of a single business algorithm or rule?
- E.g. “If the invoice total exceeds $10,000, apply a 2% volume discount; otherwise, no discount.”
- That’s activity logic: belongs in an Engine.
- E.g. “If the invoice total exceeds $10,000, apply a 2% volume discount; otherwise, no discount.”
An Engine should be reusable by other Managers. For example a FinancePolicyEngine could be used to determine authorization for both a InvoiceManager as well as a PaymentManager. In both cases, the engine is responsible for applying the business rules and calculations, not deciding when to apply them or what to do with the results.