03 Mega-services 6 chapters

Theme 03 · Anti-Incidental Complexity

Mega-services

Explanation

Mega-services

Plain Human Explanation

A mega-service is a class or module that became the default place to put everything. At first it feels convenient. Later, every change feels risky because unrelated product decisions live side by side.

The problem is not file length by itself. The problem is ownership. If trial starts, renewals, cancellation, receipts, coupons, and admin overrides all live in one service, a small billing change can accidentally affect a different workflow.

Technical Explanation

In TypeScript, mega-services often show up as classes with many dependencies, broad methods such as update or process, and private helpers that share hidden mutable state. Tests become brittle because the only way to exercise one workflow is to construct the whole service.

Split when the service has multiple reasons to change. Prefer workflow-sized functions or modules with specific inputs and specific ports. The goal is not many tiny files; the goal is code where a product change points to an obvious owner.

Why It Matters

  • User impact: billing, access, and notification changes stay predictable because unrelated workflows are not tangled together.
  • Product behavior: each workflow can state its own promise clearly.
  • Risk: one service can collect too much hidden coupling, making reviews slow and regressions easy.
  • Decision point: split when two product changes would touch the same service for unrelated reasons.

The Core Move

Carve out workflows that can be understood, tested, and changed on their own. Keep shared dependencies small and explicit, and let product responsibilities define the module boundaries.

Small Example

Mega-services: Small Example

Bad TypeScript Example

type BillingEvent = { kind: "cancel"; userId: string } | { kind: "coupon"; userId: string; code: string };

export class BillingService {
  async process(event: BillingEvent) {
    if (event.kind === "cancel") {
      return { userId: event.userId, status: "cancelled" };
    }

    if (event.code === "SAVE10") {
      return { userId: event.userId, discountPercent: 10 };
    }

    return { userId: event.userId, discountPercent: 0 };
  }
}
type BillingEvent =
  | {
      kind: "cancel";
      userId: string;
    }
  | {
      kind: "coupon";
      userId: string;
      code: string;
    };

export class BillingService {
  async process(event: BillingEvent) {
    if (event.kind === "cancel") {
      return {
        userId: event.userId,
        status: "cancelled",
      };
    }

    if (event.code === "SAVE10") {
      return {
        userId: event.userId,
        discountPercent: 10,
      };
    }

    return {
      userId: event.userId,
      discountPercent: 0,
    };
  }
}

Good TypeScript Example

type CancellationPlan = {
  userId: string;
  accessEnds: "period-end";
};
type CouponResult = {
  userId: string;
  discountPercent: 0 | 10;
};

export function planSubscriptionCancellation(userId: string): CancellationPlan {
  return { userId, accessEnds: "period-end" };
}

export function applyMarketingCoupon(userId: string, code: string): CouponResult {
  return {
    userId,
    discountPercent: code === "SAVE10" ? 10 : 0,
  };
}
type CancellationPlan = {
  userId: string;
  accessEnds: "period-end";
};
type CouponResult = {
  userId: string;
  discountPercent: 0 | 10;
};

export function planSubscriptionCancellation(
  userId: string,
): CancellationPlan {
  return {
    userId,
    accessEnds: "period-end",
  };
}

export function applyMarketingCoupon(
  userId: string,
  code: string,
): CouponResult {
  return {
    userId,
    discountPercent:
      code === "SAVE10" ? 10 : 0,
  };
}

What Changed

  • The bad version groups cancellation and coupons because both are “billing,” even though they change for different reasons.
  • The good version gives each workflow a small owner with a clear return shape.
  • Tests can cover cancellation rules without constructing coupon behavior.

Realistic Example

Mega-services: Realistic Example

This example uses subscription cancellation. The bad version forces a simple cancellation change through a service that also knows about invoices, coupons, and email.

Bad TypeScript Example

type BillingClients = {
  subscriptions: {
    update(userId: string, fields: object): Promise<void>;
  };
  invoices: {
    create(userId: string): Promise<void>;
  };
  coupons: {
    redeem(userId: string, code: string): Promise<void>;
  };
  email: {
    send(to: string, body: string): Promise<void>;
  };
};

export class BillingService {
  constructor(private clients: BillingClients) {}

  async cancel(userId: string, email: string) {
    await this.clients.subscriptions.update(userId, { cancelAtPeriodEnd: true });
    await this.clients.invoices.create(userId);
    await this.clients.email.send(email, "Your billing changed.");
  }

  async redeemCoupon(userId: string, code: string) {
    await this.clients.coupons.redeem(userId, code);
  }
}
type BillingClients = {
  subscriptions: {
    update(
      userId: string,
      fields: object,
    ): Promise<void>;
  };
  invoices: {
    create(userId: string): Promise<void>;
  };
  coupons: {
    redeem(
      userId: string,
      code: string,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      body: string,
    ): Promise<void>;
  };
};

export class BillingService {
  constructor(
    private clients: BillingClients,
  ) {}

  async cancel(
    userId: string,
    email: string,
  ) {
    await this.clients.subscriptions.update(
      userId,
      {
        cancelAtPeriodEnd: true,
      },
    );

    await this.clients.invoices.create(
      userId,
    );

    await this.clients.email.send(
      email,
      "Your billing changed.",
    );
  }

  async redeemCoupon(
    userId: string,
    code: string,
  ) {
    await this.clients.coupons.redeem(
      userId,
      code,
    );
  }
}

Good TypeScript Example

type CancelSubscriptionCommand = {
  userId: string;
  email: string;
};

type CancelSubscriptionPorts = {
  subscriptions: {
    cancelAtPeriodEnd(userId: string): Promise<void>;
  };
  email: {
    sendCancellationNotice(to: string): Promise<void>;
  };
};

type CancelSubscriptionResult = {
  userId: string;
  accessEnds: "period-end";
};

export async function cancelSubscription(command: CancelSubscriptionCommand, ports: CancelSubscriptionPorts): Promise<CancelSubscriptionResult> {
  await ports.subscriptions.cancelAtPeriodEnd(command.userId);
  await ports.email.sendCancellationNotice(command.email);

  return { userId: command.userId, accessEnds: "period-end" };
}
type CancelSubscriptionCommand = {
  userId: string;
  email: string;
};

type CancelSubscriptionPorts = {
  subscriptions: {
    cancelAtPeriodEnd(
      userId: string,
    ): Promise<void>;
  };
  email: {
    sendCancellationNotice(
      to: string,
    ): Promise<void>;
  };
};

type CancelSubscriptionResult = {
  userId: string;
  accessEnds: "period-end";
};

export async function cancelSubscription(
  command: CancelSubscriptionCommand,
  ports: CancelSubscriptionPorts,
): Promise<CancelSubscriptionResult> {
  await ports.subscriptions.cancelAtPeriodEnd(
    command.userId,
  );

  await ports.email.sendCancellationNotice(
    command.email,
  );

  return {
    userId: command.userId,
    accessEnds: "period-end",
  };
}

What Changed

  • The bad version makes cancellation depend on invoice and coupon clients it does not need.
  • The good version gives cancellation its own command and ports.
  • The workflow return value states the product promise: access continues until the period ends.

System Example

Mega-services: System Example

At system scale, a mega-service becomes a hidden map of the whole product. The good version keeps orchestration explicit while letting each workflow own its rules.

Larger System-Level Bad TypeScript Example

type AppClients = {
  db: {
    write(table: string, row: object): Promise<void>;
  };
  email: {
    send(to: string, body: string): Promise<void>;
  };
  billing: {
    charge(userId: string, cents: number): Promise<void>;
  };
  analytics: {
    track(name: string, data: object): Promise<void>;
  };
};

export class AccountService {
  constructor(private clients: AppClients) {}

  async register(input: { userId: string; email: string }) {
    await this.clients.db.write("users", input);
    await this.clients.email.send(input.email, "Welcome");
  }

  async chargeInvoice(input: { userId: string; amountCents: number }) {
    await this.clients.billing.charge(input.userId, input.amountCents);
    await this.clients.db.write("invoices", input);
  }

  async changeEmail(input: { userId: string; email: string }) {
    await this.clients.db.write("users", input);
    await this.clients.analytics.track("email_changed", input);
  }
}
type AppClients = {
  db: {
    write(
      table: string,
      row: object,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      body: string,
    ): Promise<void>;
  };
  billing: {
    charge(
      userId: string,
      cents: number,
    ): Promise<void>;
  };
  analytics: {
    track(
      name: string,
      data: object,
    ): Promise<void>;
  };
};

export class AccountService {
  constructor(
    private clients: AppClients,
  ) {}

  async register(input: {
    userId: string;
    email: string;
  }) {
    await this.clients.db.write(
      "users",
      input,
    );

    await this.clients.email.send(
      input.email,
      "Welcome",
    );
  }

  async chargeInvoice(input: {
    userId: string;
    amountCents: number;
  }) {
    await this.clients.billing.charge(
      input.userId,
      input.amountCents,
    );

    await this.clients.db.write(
      "invoices",
      input,
    );
  }

  async changeEmail(input: {
    userId: string;
    email: string;
  }) {
    await this.clients.db.write(
      "users",
      input,
    );

    await this.clients.analytics.track(
      "email_changed",
      input,
    );
  }
}

Larger System-Level Good TypeScript Example

type RegistrationPorts = {
  users: {
    create(input: { userId: string; email: string }): Promise<void>;
  };
  email: {
    sendWelcome(to: string): Promise<void>;
  };
};

type InvoicePorts = {
  billing: {
    charge(userId: string, cents: number): Promise<void>;
  };
  invoices: {
    record(input: { userId: string; amountCents: number }): Promise<void>;
  };
};

type InvoiceChargeResult = {
  kind: "invoice-charged";
  userId: string;
};

export async function registerAccount(
  input: {
    userId: string;
    email: string;
  },
  ports: RegistrationPorts,
) {
  await ports.users.create(input);
  await ports.email.sendWelcome(input.email);
}

export async function chargeInvoice(
  input: {
    userId: string;
    amountCents: number;
  },
  ports: InvoicePorts,
): Promise<InvoiceChargeResult> {
  await ports.billing.charge(input.userId, input.amountCents);
  await ports.invoices.record(input);

  return { kind: "invoice-charged", userId: input.userId };
}
type RegistrationPorts = {
  users: {
    create(input: {
      userId: string;
      email: string;
    }): Promise<void>;
  };
  email: {
    sendWelcome(to: string): Promise<void>;
  };
};

type InvoicePorts = {
  billing: {
    charge(
      userId: string,
      cents: number,
    ): Promise<void>;
  };
  invoices: {
    record(input: {
      userId: string;
      amountCents: number;
    }): Promise<void>;
  };
};

type InvoiceChargeResult = {
  kind: "invoice-charged";
  userId: string;
};

export async function registerAccount(
  input: {
    userId: string;
    email: string;
  },
  ports: RegistrationPorts,
) {
  await ports.users.create(input);

  await ports.email.sendWelcome(
    input.email,
  );
}

export async function chargeInvoice(
  input: {
    userId: string;
    amountCents: number;
  },
  ports: InvoicePorts,
): Promise<InvoiceChargeResult> {
  await ports.billing.charge(
    input.userId,
    input.amountCents,
  );

  await ports.invoices.record(input);

  return {
    kind: "invoice-charged",
    userId: input.userId,
  };
}

What Changed

  • The bad version makes account registration, invoices, and profile changes share one service surface.
  • The good version splits by workflow, so each function has only the dependencies it needs.
  • Shared clients can still exist underneath, but product rules no longer live in one oversized class.

When To Use It

Mega-services: When To Use It

Use This When

  • One service has several unrelated reasons to change.
  • Tests for one workflow require setting up many dependencies that workflow does not use.
  • Method names become broad, such as process, update, sync, or handle.

Avoid This When

  • The service is large because the workflow itself is large, but its responsibility is still cohesive.
  • Splitting would force every caller to understand more coordination details.
  • The code is stable, low-risk, and the team is not paying a real change cost.

Tradeoffs

Splitting a mega-service can create more files. That is only a win when each file has a clearer owner. Prefer a few workflow modules over a spray of tiny classes.

  • Shallow abstractions
  • Vague helpers
  • Repository-per-table patterns

Practice Prompt

Mega-services: Practice Prompt

Beginner Exercise

Open a large service and list the product workflows it owns. Mark which ones change for different reasons.

Intermediate Exercise

Extract one workflow into a function or module with only the inputs and dependencies it needs. Do not introduce a new generic manager.

Stretch Exercise

Write a test for the extracted workflow that does not construct the old mega-service. Assert the observable product result.

Reflection Question

Did the extraction reduce the amount of unrelated context needed to make a safe change?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.