a typescript monorepo that sucks less

Josef Erben

Jun 10, 2024

Monorepo tooling has improved, yet monorepos still suck.

But out of all the ways to organize code, monorepos might suck the least.

A lot of pain around monorepos comes from building and transpiling TypeScript or JSX. When packages with different build processes depend on each other, it can get really hairy.

Our monorepo tooling at beeps is built with Turborepo, pnpm, changesets, tsup and tsx. It’s not perfect, but it allows us to focus on software architecture in the context of Next.js apps and TypeScript services.

In this article we’ll build a TypeScript monorepo that sucks less and grows with your team.

Let’s define a monorepo that sucks less as one that:

  1. makes it obvious where to find code

  2. makes it obvious where to put code

  3. builds fast

  4. allows focus on a single app while ignoring the rest

The tooling mentioned earlier takes care of 3 & 4. Let’s have a look at 1 & 2, by managing dependencies within the monorepo.

Directory Structure

The beeps monorepo resembles the example below. It follows Turborepo’s blessed structure with apps and packages:

  1. packages: things that can be included

  2. apps: things that can be started and stopped

.├── apps│   ├── incident-dashboard (Next.js app)│   └── incident-service (Express service)└── packages    ├── db    ├── ai    ├── domain    │   └── src    │       ├── entities.ts    │       ├── features.ts    │       ├── queries.ts    │       └── schema.ts    └── ui

This distinction is useful, but it’s not enough to make sure we don’t hate our monorepo in a few months!

Domain vs. Infrastructure

If there is one thing to take away from this article, then it’s the distinction between domain and infrastructure. Domain is all about business rules, infrastructure is everything else.

In an ideal world, engineering teams would be spending all their time with domain code. This is what customers care about. Infrastructure on the other hand, is a necessary evil. As a SaaS business, we use HTTP not because we are deeply passionate about it, but because that’s how our customers use our services. We use databases because we have to store our data somehow. If there was a magical alien device that would store data in a better way, we’d use that instead!

As an on-call platform we want to make on-call easier and less painful. HTTP, TypeScript, databases even electricity and silicon are just means to that end.

Some of the domain examples we encounter are:

  • How is an incident defined?

  • When should an alert be sent?

  • Should we deliver that alert via SMS or Slack?

And a few of our infrastructure concerns:

  • Are we using Twilio or Sinch to send SMS?

  • Which Express middleware should be enabled for our services?

  • Is the toaster animation smooth?

The business rules live in the domain, the infrastructure connects the domain to the real world by allowing customers to interact with the core business rules.

This doesn’t mean that infrastructure is any less important! Infrastructure determines the look and feel of an app, how fast it runs or whether it can be accessed via browser or app store.

With this distinction out of the way, let’s dig into some code! 👩‍💻 We will go through an example on-call domain layer by layer, starting at the center with layer 1.

.├── apps│   ├── incident-dashboard (Next.js app)     <-- app layer│   └── incident-service (Express service)   <-- app layer└── packages    ├── db    ├── domain                               <-- domain logic    │   └── src    │       ├── schema.ts                    <--- layer 1    │       ├── entities.ts                  <--- layer 2    │       ├── features.ts                  <--- layer 3    │       └── queries.ts                   <--- layer 3    └── ui

Layer 1: schema.ts

The center of our onion is the data model. All other layers are built on top of it.

export const alertsTable = pgTable("alerts", {  id: varchar("id", { length: 191 }).primaryKey(),  message: varchar("message"),  severity: integer("severity"),  isAcknowledged: boolean("is_acknowledged"),  incidentId: varchar("incident_id"),  maintenanceWindow: varchar("maintenance_window"),})

Layer 2: entities.ts

TheAlert entity has a one-to-one mapping to the database row type:

type Alert = alertsTable.$inferSelect

This maps Alert type to the alertsTable. A row in alertsTable is equivalent to an Alert instance in terms of type.

What happens when we introduce different types of alerts? Let's say we want to differentiate between incident alerts and maintenance alerts. We can introduce a new "type" column in the database schema to indicate the type of alert:

export const alertsTable = pgTable("alerts", {  id: varchar("id", { length: 191 }).primaryKey(),  type: varchar("type", { enum: ["maintenance", "incident"] }), // +  message: varchar("message"),  severity: integer("severity"),  isAcknowledged: boolean("is_acknowledged"),  incidentId: varchar("incident_id"),  maintenanceWindow: varchar("maintenance_window"),})

With this change, the one-to-one mapping between Alert and the database table breaks down. Let’s now introduce separate types for incident alerts and maintenance alerts:

type IncidentAlert = {  id: string  type: "incident"  message: string  severity: number  incidentId: string}
type MaintenanceAlert = {  id: string  type: "maintenance"  message: string  severity: number  maintenanceWindow: string}
type Alert = IncidentAlert | MaintenanceAlert

We just did a bit of type-driven domain modelling! 🥳

The union type Alert represents an incident alert or a maintenance alert. This allows us to model this this domain concept more accurately using the type system.

To create new alerts, we introduce make functions that contain the creation logic and perform validation:

function makeIncidentAlert(alertData: Omit<IncidentAlert, "id">): IncidentAlert | string {  if (alertData.severity > 5) {    return "Incident severity cannot exceed 5";  }  return { ...alertData, id: generateId(), type: "incident" };}
function makeMaintenanceAlert(alertData: Omit<MaintenanceAlert, "id">): MaintenanceAlert | string {  if (!isValidMaintenanceWindow(alertData.maintenanceWindow)) {    return "Invalid maintenance window";  }  return { ...alertData, id: generateId(), type: "maintenance" };}

The make functions take in the necessary data to create an alert, perform validation, and return either a valid alert entity or a string error message. These functions are pure, which means they don’t use fetch or write to the database. This makes them easy to test and understand.

Our domain entities IncidentAlert and MaintenanceAlert introduce business concepts on top of the persisted data model using types.

This approach ensures that layers above entities.ts operate on business entities instead of raw database rows. Higher layers implement business rules in a type-safe way and evolve independently from the database schema, by using entities instead of rows.

Layer 3: features & queries

features.ts

This is where most of the business rules live.

Let’s look at an example function that processes a specific type of alert:

export async function processIncidentAlert(  ctx: { db: Database },  alert: IncidentAlert): Promise<undefined | string> {  // Process the incident alert  // ...
  // Example business rule: Incident alerts with severity > 3 require immediate attention  if (alert.severity > 3) {    await sendImmediateNotification(alert);  }
  // Example business rule: Incident alerts must be acknowledged within 24 hours  if (!alert.isAcknowledged && isOlderThan24Hours(alert)) {    await escalateIncident(alert);  }
  // Persist the updated alert  await ctx.db.update(alertsTable).set({ isAcknowledged: true }).where({ id: alert.id });
  return undefined;}

processIncidentAlert takes an IncidentAlert and performs specific business logic based on the alert. It encapsulates rules such as sending immediate notifications for high-severity alerts and escalating incidents that are not acknowledged within 24 hours.

A couple of things to keep in mind for features:

  • Type safety: In this layer, we forget about how entities are persisted and rely on the type checker and IntelliSense

  • Transaction boundaries: Start out with atomic features and narrow down the transaction scope to make things fast.

  • Context: Pass in the database connection using ctx. This is important! domain is a package and doesn’t have a lifecycle, so it can’t maintain a database handle.

  • Keep it lightweight: Instead of dependencies use peerDependencies for infrastructure bits that need type-safety. Provide any database handles, loggers or background queues using ctx .

queries.ts

Here's an example signature of a function in queries.ts:

export function getAlertById(ctx: { db: Database }, id: string): Promise<Alert | null>;

Queries return entities. Not much emphasis is put on this layer other than there is a distinction between doing (features.ts) and reading (queries.ts ). Reading is usually not the source of complexity, so queries.ts exists to emphasize how crucial features.ts is!

A couple of things to keep in mind:

  • Authorization: It’s tempting to check whether someone is allowed to read data, do this in the app layer instead.

  • Start with ad-hoc queries: Start out by querying data where you need it and as queries are repeated move them into queries.ts.

  • Optimize indices: Regularly adjust database indices based on these data access in queries.ts.

App Layer: Next.js Apps and Services

The app layer is where the domain interacts with the outside world using infrastructure components. This is where we handle HTTP requests, interact with databases, and integrate with external services.

Below is a Next.js API route to process an incident alert.

import { processIncidentAlert } from "domain/src/features";import { getAlertById } from "domain/src/queries";
export default async function handler(req, res) {  if (req.method !== "POST") {    return res.status(405).json({ error: "Method not allowed" });  }
  const alertId = req.body.alertId;
  if (!alertId) {    return res.status(400).json({ error: "Alert ID is required" });  }
  const db = await connectToDatabase();  const ctx = { db };
  const alert = await getAlertById(ctx, alertId);
  if (!alert) {    return res.status(404).json({ error: "Alert not found" });  }
  if (alert.type !== "incident") {    return res.status(400).json({ error: "Only incident alerts can be processed" });  }
  const result = await processIncidentAlert(ctx, alert);
  if (typeof result === "string") {    // Domain error occurred    return res.status(400).json({ error: result });  }
  return res.status(200).json({ message: "Incident alert processed successfully" });}

processIncidentAlert takes an IncidentAlert and forces the Next.js route to first find the right entity and handle any invalid alertId by returning 404. Our architecture enforces that only existing and valid entity instances operate on features.ts.

Note that getAlertById(ctx, alertId) returns undefined instead of a 404. Within the domain layer, there is no notion of HTTP. The app layer maps domain errors to infrastructure specific errors.

Error Handling

Interesting things happen when we leave the happy path. Unhappy paths are what make this architecture shine.

Unhappy paths are a part of reality and not a problem per se. The manager tries to put an engineer on rotation when they are out-of-office. Or an on-call engineer runs out of mobile data right after acknowledging the incident.

Unhappy paths become a problem only if they are not accounted for! Let’s handle them explicitly in features.ts so that we can anticipate unhappy paths and test for them.

We define 2 types of errors:

  • User error: The user provided invalid data or some business rule invariant was violated, e.g. “Can’t put engineer on rotation on that date because engineer is out of office”.

  • System error: An infrastructure component failed, e.g. “Database connection lost” or “AWS is down”.

Do you see a pattern?

  • User error: Originated from domain, needs to be shown to user, often recoverable by user

  • System error: Originated from infra, needs to bubble up to the app layer where it gets caught/logged, a generic error should be shown to the user to not leak details, often not recoverable

The example below showcases both types of errors:

// features.tsexport async function assignEngineerToRotation(  ctx: { db: Database; logger: Logger },  engineer: Engineer,  date: Date): Promise<string | void> {  if (await isEngineerOnVacation(ctx, engineer.id, date)) {    return "Engineer is on vacation on the specified date"; // User error: business rule violation  }
  try {    await ctx.db.insert(engineerRotationTable).values({ engineerId: engineer.id, date });  } catch (error) {    ctx.logger.error("Error assigning engineer to rotation:", error);    throw error; // Rethrow the same error  }}
// app.tsimport { assignEngineerToRotation } from "domain/src/features";import { getEngineerById } from "domain/src/queries";
app.post("/api/rotation", async (req, res) => {  const { engineerId, date } = req.body;  const logger = req.log; // Assuming the logger is attached to the request object
  try {    const engineer = await getEngineerById({ db, logger }, engineerId);
    if (!engineer) {      return res.status(404).json({ error: "Engineer not found" }); // User error: engineer doesn't exist    }
    const result = await assignEngineerToRotation({ db, logger }, engineer, new Date(date));
    if (typeof result === "string") {      logger.info("User error:", result);      return res.status(400).json({ error: result }); // User error: business rule violation    }
    return res.status(200).json({ message: "Engineer assigned to rotation successfully" });  } catch (error) {    // System error occurred    logger.error("Unexpected error:", error);    return res.status(500).json({ error: "An unexpected error occurred" });  }});

Bonus: Testing

Functions in query.ts and feature.ts take a context to access infrastructure bits.

Use this to inject dependencies for testing:

  • Database: Provide a database handle that rolls back the current transaction after a test run to run tests in isolation against a real database.

  • Task queue: Use a test task queue that runs tasks immediately bypassing the queue.

  • Emails: When runnings tests that involve sending out emails, provide an email implementation that instead of using the real service prints the email to the console

import { processIncidentAlert } from "domain/features";import { createTestContext } from "domain/test-utils";
describe("processIncidentAlert", () => {  it("should process an incident alert", async () => {    const ctx = createTestContext();
    // Create an incident alert    const alertId = await createIncidentAlert(ctx, {      message: "Incident occurred",      severity: 1,      incidentId: "inc_123",    });
    const result = await processIncidentAlert(ctx, alertId);
    expect(typeof result).not.toBe("string");    expect(result.message).toBe("Incident occurred");    expect(result.severity).toBe(1);    expect(result.incidentId).toBe("inc_123");  });
  // ...});

This allows you to fully test your business rules!

General principles to follow

Some principles to follow in order to make the most out of this architecture:

  • Authorization: This is where domain and infra meet and it’s tempting to move authorization into domain. Don’t!

  • Stateless and pure: Avoid state and side-effects in entities.ts to keep things testable and easy to reason about.

  • Keep it dependency free: In a world where frontend compilers split code bases to run them in browsers and constrained serverless runtimes, it’s important to keep domain free of dependencies. Avoid using Node APIs and pass in things like fetch to avoid headaches.

  • Use first: When implementing a feature, start with the app/service layer. Implement the Next page or the endpoint pretending features.ts and queries.ts have the functions you need. Once you have a good idea of how you want to use the layer below, implement it. Repeat this process until you hit schema.ts.

Putting it all together

Following this architecture will not magically make a monorepo perfect, but it will suck less. Code will become complex and complexity creates issues. Issues cause incidents, and eventually someone has to wake up in the middle of the night.

We really don’t like when this happens, that’s why we’re building beeps. If you want to learn more about what we’re building and be an early user of our platform, schedule some time with us!

Read more

A primer on domain-driven design and the importance of separating infrastructure and domain.

An article about the importance of keeping the code pure, stateless and side-effect free (also available as a talk)