Theme 03 · Anti-Incidental Complexity
Shallow abstractions
Explanation
Shallow abstractions
Plain Human Explanation
A layer should earn the extra click it asks from the reader. If a function only renames another function, the product behavior is not easier to understand. It is just farther away.
A useful abstraction makes the important idea smaller: it names a policy, protects an invariant, or hides a messy boundary. A shallow abstraction gives you a new name without removing any real complexity.
Technical Explanation
In TypeScript, shallow abstractions often look like manager classes, service wrappers, or helper functions that pass the same fields through to another API. The caller still has to understand the underlying API, but now they also have to inspect the wrapper.
Prefer the direct call until the abstraction can say something meaningful: “register a trial account,” “price a renewal invoice,” or “record a refund.” If the new function has the same inputs, same outputs, and same failure modes as the thing it wraps, it is probably not helping yet.
Why It Matters
- User impact: fewer product bugs slip through because reviewers can see the real rule in one place.
- Product behavior: important workflows stay named after user outcomes, not after generic layers.
- Risk: shallow wrappers make simple changes feel bigger and hide the line that actually needs review.
- Decision point: keep a wrapper when it reduces repeated policy or protects a boundary; remove it when it only forwards a call.
The Core Move
Collapse wrappers that do not change the level of understanding. When you add a function or class, make sure its name, inputs, and return value explain a product idea the lower-level API does not.
Small Example
Shallow abstractions: Small Example
Bad TypeScript Example
type Price = {
amountCents: number;
currency: "USD";
};
function makePrice(amountCents: number): Price {
return { amountCents, currency: "USD" };
}
export function createInvoiceLine(label: string, amountCents: number) {
return {
label,
price: makePrice(amountCents),
};
}
type Price = {
amountCents: number;
currency: "USD";
};
function makePrice(
amountCents: number,
): Price {
return {
amountCents,
currency: "USD",
};
}
export function createInvoiceLine(
label: string,
amountCents: number,
) {
return {
label,
price: makePrice(amountCents),
};
}
Good TypeScript Example
type Price = {
amountCents: number;
currency: "USD";
};
type InvoiceLine =
| { ok: true; label: string; price: Price }
| { ok: false; error: "invalid-amount" };
export function createInvoiceLine(label: string, amountCents: number): InvoiceLine {
if (!Number.isInteger(amountCents) || amountCents < 0) {
return { ok: false, error: "invalid-amount" };
}
return {
ok: true,
label,
price: { amountCents, currency: "USD" },
};
}
type Price = {
amountCents: number;
currency: "USD";
};
type InvoiceLine =
| {
ok: true;
label: string;
price: Price;
}
| {
ok: false;
error: "invalid-amount";
};
export function createInvoiceLine(
label: string,
amountCents: number,
): InvoiceLine {
if (
!Number.isInteger(amountCents) ||
amountCents < 0
) {
return {
ok: false,
error: "invalid-amount",
};
}
return {
ok: true,
label,
price: {
amountCents,
currency: "USD",
},
};
}
What Changed
- The bad version adds
makePrice, but the wrapper does not validate, rename a product rule, or hide a difficult boundary. - The good version puts the invoice rule where the line is created: amounts must be whole cents and cannot be negative.
- The remaining structure explains a real decision instead of creating a pass-through layer.
Realistic Example
Shallow abstractions: Realistic Example
This example uses trial registration, where a wrapper around the database looks organized but does not actually clarify the workflow.
Bad TypeScript Example
type TrialInput = {
userId: string;
email: string;
};
type UserDatabase = {
users: {
insert(row: { userId: string; email: string; status: string }): Promise<{
id: string;
}>;
};
};
class UserCreator {
constructor(private db: UserDatabase) {}
create(input: TrialInput) {
return this.db.users.insert({ ...input, status: "trial" });
}
}
export function registerUser(input: TrialInput, db: UserDatabase) {
return new UserCreator(db).create(input);
}
type TrialInput = {
userId: string;
email: string;
};
type UserDatabase = {
users: {
insert(row: {
userId: string;
email: string;
status: string;
}): Promise<{
id: string;
}>;
};
};
class UserCreator {
constructor(private db: UserDatabase) {}
create(input: TrialInput) {
return this.db.users.insert({
...input,
status: "trial",
});
}
}
export function registerUser(
input: TrialInput,
db: UserDatabase,
) {
return new UserCreator(db).create(input);
}
Good TypeScript Example
type TrialRegistration = {
userId: string;
email: string;
};
type TrialUserRecord = TrialRegistration & {
status: "trial";
trialEndsAt: Date;
};
type TrialUserStore = {
createTrialUser(record: TrialUserRecord): Promise<{
id: string;
}>;
};
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function registerTrialUser(input: TrialRegistration, store: TrialUserStore, now: Date) {
const trialEndsAt = addDays(now, 14);
return store.createTrialUser({
...input,
status: "trial",
trialEndsAt,
});
}
type TrialRegistration = {
userId: string;
email: string;
};
type TrialUserRecord = TrialRegistration & {
status: "trial";
trialEndsAt: Date;
};
type TrialUserStore = {
createTrialUser(
record: TrialUserRecord,
): Promise<{
id: string;
}>;
};
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function registerTrialUser(
input: TrialRegistration,
store: TrialUserStore,
now: Date,
) {
const trialEndsAt = addDays(now, 14);
return store.createTrialUser({
...input,
status: "trial",
trialEndsAt,
});
}
What Changed
- The bad version wraps
db.users.insertwithout making the registration rule easier to see. - The good version names the product workflow and includes the real policy: a trial lasts 14 days.
- The store boundary is still small, but it exists because the workflow needs persistence, not because every call needs a class.
System Example
Shallow abstractions: System Example
At system scale, shallow abstractions often appear as service classes that only rename clients. The codebase feels layered, but the product workflow is still hidden.
Larger System-Level Bad TypeScript Example
type RegistrationInput = {
userId: string;
email: string;
};
type UserClient = {
create(input: RegistrationInput): Promise<{
id: string;
email: string;
}>;
};
type EmailClient = {
send(to: string, subject: string): Promise<void>;
};
type AuditClient = {
write(event: string, data: object): Promise<void>;
};
class UserService {
constructor(private users: UserClient) {}
create(input: RegistrationInput) {
return this.users.create(input);
}
}
class EmailService {
constructor(private email: EmailClient) {}
sendWelcome(to: string) {
return this.email.send(to, "Welcome");
}
}
export class RegistrationManager {
constructor(
private users: UserService,
private email: EmailService,
private audit: AuditClient,
) {}
async run(input: RegistrationInput) {
const user = await this.users.create(input);
await this.email.sendWelcome(user.email);
await this.audit.write("user.created", user);
return user;
}
}
type RegistrationInput = {
userId: string;
email: string;
};
type UserClient = {
create(
input: RegistrationInput,
): Promise<{
id: string;
email: string;
}>;
};
type EmailClient = {
send(
to: string,
subject: string,
): Promise<void>;
};
type AuditClient = {
write(
event: string,
data: object,
): Promise<void>;
};
class UserService {
constructor(private users: UserClient) {}
create(input: RegistrationInput) {
return this.users.create(input);
}
}
class EmailService {
constructor(private email: EmailClient) {}
sendWelcome(to: string) {
return this.email.send(to, "Welcome");
}
}
export class RegistrationManager {
constructor(
private users: UserService,
private email: EmailService,
private audit: AuditClient,
) {}
async run(input: RegistrationInput) {
const user =
await this.users.create(input);
await this.email.sendWelcome(
user.email,
);
await this.audit.write(
"user.created",
user,
);
return user;
}
}
Larger System-Level Good TypeScript Example
type RegistrationInput = {
userId: string;
email: string;
};
type RegisteredUser = {
id: string;
email: string;
trialEndsAt: Date;
};
type RegistrationPorts = {
users: {
createTrial(
input: RegistrationInput & {
trialEndsAt: Date;
},
): Promise<RegisteredUser>;
};
email: {
sendWelcome(to: string): Promise<void>;
};
audit: {
record(
event: "trial_account.registered",
data: {
userId: string;
},
): Promise<void>;
};
};
function trialEndDate(now: Date) {
const next = new Date(now);
next.setDate(next.getDate() + 14);
return next;
}
export async function registerTrialAccount(input: RegistrationInput, ports: RegistrationPorts, now: Date) {
const user = await ports.users.createTrial({
...input,
trialEndsAt: trialEndDate(now),
});
await ports.email.sendWelcome(user.email);
await ports.audit.record("trial_account.registered", { userId: user.id });
return user;
}
type RegistrationInput = {
userId: string;
email: string;
};
type RegisteredUser = {
id: string;
email: string;
trialEndsAt: Date;
};
type RegistrationPorts = {
users: {
createTrial(
input: RegistrationInput & {
trialEndsAt: Date;
},
): Promise<RegisteredUser>;
};
email: {
sendWelcome(to: string): Promise<void>;
};
audit: {
record(
event: "trial_account.registered",
data: {
userId: string;
},
): Promise<void>;
};
};
function trialEndDate(now: Date) {
const next = new Date(now);
next.setDate(next.getDate() + 14);
return next;
}
export async function registerTrialAccount(
input: RegistrationInput,
ports: RegistrationPorts,
now: Date,
) {
const user =
await ports.users.createTrial({
...input,
trialEndsAt: trialEndDate(now),
});
await ports.email.sendWelcome(user.email);
await ports.audit.record(
"trial_account.registered",
{
userId: user.id,
},
);
return user;
}
What Changed
- The bad version has several services, but most of them only forward calls.
- The good version keeps the workflow visible and names the one product rule the system cares about.
- The ports are small because they describe real outside effects, not because every client needs a wrapper class.
When To Use It
Shallow abstractions: When To Use It
Use This When
- A wrapper makes a simple change require opening several files.
- A new layer has nearly the same inputs and outputs as the thing underneath it.
- The abstraction name is generic, such as manager, service, adapter, or helper, and the code inside is mostly forwarding.
Avoid This When
- The wrapper names a stable product operation that callers should not rebuild.
- The wrapper hides a genuinely messy external API and presents a cleaner local contract.
- Several callers need the same policy, validation, or transaction boundary.
Tradeoffs
Removing a wrapper can feel like making the code “less architected.” The payoff is that readers see the product behavior directly. Keep the abstraction only when it reduces the amount a caller needs to know.
Related Concepts
- Vague helpers
- Mega-services
- Repository-per-table patterns
Practice Prompt
Shallow abstractions: Practice Prompt
Beginner Exercise
Find a wrapper function or class that mostly forwards to another call. Write down what extra product meaning it adds.
Intermediate Exercise
Inline one shallow wrapper or rename it so it captures a real product operation. Keep the public behavior unchanged.
Stretch Exercise
Find two wrappers around the same dependency. Replace them with one small port or direct call, then update the tests so they still assert user-visible behavior.
Reflection Question
After the change, does a reader need to open fewer files to understand the same product rule?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.