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, orhandle.
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.
Related Concepts
- 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.