01 / The idea
Typing the mailer with the SMTP client is a fair start.
The password-reset code already takes its mailer as a parameter. The natural type for that
parameter is the class you’re passing: SmtpClient, imported from the SMTP
module. Autocomplete works, there’s one class to read, and nothing is sent that the client
doesn’t understand.
Read the first reset codeTypeScript · the version this lesson starts from
// resets.ts
// The first version: the mailer is passed in, but its type comes from the SMTP module,
// so the reset code imports the provider it's meant to be independent of.
import { SmtpClient, type Email } from './smtp';
export function createPasswordResets(smtp: SmtpClient) {
return {
request(address: string): void {
const email: Email = {
to: address,
subject: 'Reset your password',
body: 'https://app.example.com/reset?token=token-1'
};
smtp.send(email);
}
};
}
Go’s version imports the smtp package for *smtp.Client the same
way. Both languages meet again at the reset code that declares its own Mailer in section 02.
Then the email team switches providers and renames SmtpClient, and the reset
code has to change and be re-tested, though it never sends mail itself. Later, the email
module’s templates need the reset request’s type. Now each imports the other, and Go won’t
build it.
Dependency direction is about source code: which file has to know about which. Point the arrows at the code you most want to keep steady, so the parts that change often, providers, frameworks, screens, depend on it rather than the other way round. Put an interface in the code that uses it, and the provider imports that interface. The calls at run time don’t change direction; only who has to know about whom. Go’s code review guidance says interfaces “generally belong in the package that uses values of the interface type, not the package that implements those values.”
Section 05 keeps a design system free of feature imports, in React and Svelte.
02 / See the shape
Declare what you need where you need it.
The basic form is the reset code declaring Mailer and importing nothing. In the wild adds the SMTP adapter, which imports that contract, and the one
file that imports both sides. At the call site reads the real import
statements of both versions and reports what a change to smtp reaches.
Both languages produce the same arrows.
The interface where it’s used. The reset code declares Mailer and Email, and imports nothing.
// resets.ts
// The reset code declares what it needs. It imports nothing.
export type Email = { to: string; subject: string; body: string };
export interface Mailer {
send(email: Email): void;
}
export function createPasswordResets(mailer: Mailer) {
return {
request(address: string): void {
mailer.send({
to: address,
subject: 'Reset your password',
body: 'https://app.example.com/reset?token=token-1'
});
}
};
}
// inverted/resets/resets.go
// Package resets declares what it needs. It imports nothing.
package resets
type Email struct{ To, Subject, Body string }
// Mailer lives here, in the package that uses it.
type Mailer interface{ Send(Email) }
type PasswordResets struct{ mailer Mailer }
func New(mailer Mailer) *PasswordResets { return &PasswordResets{mailer: mailer} }
func (r *PasswordResets) Request(address string) {
r.mailer.Send(Email{To: address, Subject: "Reset your password", Body: "https://app.example.com/reset?token=token-1"})
}
Reading the TypeScriptType-only imports and structural types
smtp.ts uses import type. TypeScript’s release notes say it
“always gets fully erased, so there’s no remnant of it at runtime,” so this arrow exists
in the source and the build, not in the running program.
Structural typing means smtp.ts wouldn’t strictly need the import.
Importing Mailer makes the compiler check the adapter against the contract, and makes the
arrow visible.
Reading the GoConsumer-owned interfaces, and no cycles
resets.Mailer lives in the package that uses it. The same guidance adds
that “the implementing package should return concrete (usually pointer or struct)
types,” which is why smtp.Client is a struct that satisfies Mailer without saying so.
Go won’t compile an import cycle. The lesson’s test builds two packages that import each
other, and go build fails with import cycle not allowed.
03 / Follow the arrows
Watch the arrows move while the calls stay put.
Five steps. Every arrow is read from the example files’ import statements, and the dashed
call arrow comes from running the code. Before each step, guess which files a change to smtp reaches.
In Try it, pick a version and a file to change, and follow the arrows back.
Which way do the arrows point?
resets imports smtp. main imports resets; main imports smtp; resets imports smtp. No cycle. The mailer is passed in, as it should be. But its type, SmtpClient, comes from the smtp module, so resets imports it.
The reset code imports the provider.
main imports both, and resets imports smtp for the SmtpClient type.
Reduced motion: choose a scene to see its completed state.
Read this scene
main imports both, and resets imports smtp for the SmtpClient type.
resets imports smtp. main imports resets; main imports smtp; resets imports smtp. No cycle. The mailer is passed in, as it should be. But its type, SmtpClient, comes from the smtp module, so resets imports it.
Watch restarts when you return. Step through keeps your selected step. Try it starts with the first version and a change to smtp each time you open it.
What pointing the arrows inward buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Provider changes stop at the edge
- A change to
smtpreachessmtpandmain, not the reset code. - Core code with no imports
resets.tsimports nothing, so it can be read and tested on its own.- No loops between core and adapters
- Every arrow between them points one way, so no cycle can form.
- New providers without touching the core
- A second adapter imports
Mailertoo;resetsdoesn’t change. - One file that knows everything
mainis the only place that imports both sides.
The review words are dependency direction, dependency inversion for moving the interface to the code that uses it, stable and volatile for how often a part changes, consumer-owned interface, and import cycle. Robert C. Martin’s dependency rule puts the goal in five words: “source code dependencies can only point inwards.” Section 08 covers what they cost.
04 / Try a decision
An arrow that flipped.
The interface moved, and the import arrow reversed. The evidence is in trace.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
A design system that never imports a feature.
In the real app, a shared design system provides banners and toasts to every feature, including the account area where password resets live. Other teams use the same components. A change to the account feature must never reach the design system, or every team’s build depends on account code.
Imports nothing from features
It defines its own words: tones, text, dismiss.
Imports the design system
It decides what the toast says after a reset.
Imports both
It puts the feature on a page and supplies the reset API.
The example leaves out the lint rule or check that would enforce the direction, toast timing, and styling.
Build UIs?Every shared component you write either imports your app or doesn’t, and one day a design-system change breaks because it knew about a feature.
Where it already is in your components
A component library you install never imports your app. You import it, pass props, and
fill its children. The textbook Banner is built the same way: it takes a tone,
content, and a dismiss callback, and imports nothing from any feature.
In Svelte the content arrives as a children snippet; in React, as children. Either way, the words come from the feature that uses the banner.
When you have to own it
Now it’s the account feature’s reset panel. It imports the design system’s toast
vocabulary, addToast, dismissToast, and the Toast type, and
decides the text after each reset. The design system’s toast.ts has no imports
at all, and the lesson’s test checks that.
If the design system ever needed something from the account feature, the answer is the same as in section 02: the design system declares what it needs, and the feature supplies it.
// Shared UI, owned by the design system. It defines its own vocabulary and imports nothing from
// any feature: features import it, never the other way round.
export type ToastTone = 'success' | 'warning';
export type Toast = { id: number; tone: ToastTone; text: string };
export function addToast(toasts: readonly Toast[], tone: ToastTone, text: string): Toast[] {
const id = toasts.reduce((max, toast) => Math.max(max, toast.id), 0) + 1;
return [...toasts, { id, tone, text }];
}
export function dismissToast(toasts: readonly Toast[], id: number): Toast[] {
return toasts.filter((toast) => toast.id !== id);
}
A shared Banner in the design system that takes its content as props and imports nothing from a feature.
// ui/Banner.tsx — shared UI. It takes what it shows as props and imports nothing from a feature.
import type { ReactNode } from 'react';
export function Banner({
tone,
children,
onDismiss
}: {
tone: 'success' | 'warning';
children: ReactNode;
onDismiss: () => void;
}) {
return (
<div role="status" data-tone={tone}>
{children}
<button type="button" onClick={onDismiss} aria-label="Dismiss">
×
</button>
</div>
);
}
// features/account/ResetSent.tsx would import Banner and pass the account's own text:
// <Banner tone="success" onDismiss={close}>We sent a reset link to {address}</Banner>
06 / Recognize it elsewhere
Anywhere a steady part and a changing part meet.
You’ve met all of these. For each one, find which way the arrow points and what it protects.
| Where you’ve seen it | Which way it points | What it protects |
|---|---|---|
| A component library from npm | Your app → the library | The library never changes because your app did |
features/ and ui/ folders | Features → UI | Shared pieces stay reusable |
| Go database drivers | Drivers → database/sql/driver | Your code uses database/sql, whichever driver is installed |
| A plugin system | Plugins → the host’s plugin types | The host doesn’t change for each plugin |
| Shared types for a client and a server | Both → the shared types | Neither side imports the other |
Before adding an import, ask which side changes more often. The arrow should point from that side to the steadier one.
07 / Already in your toolbox
The rule is already written down.
Three places to look. For each one, find where the interface lives and which way the arrows point.
Go · Code Review Comments, Interfaces
Interfaces in the package that uses them, concrete types from the package that implements them, and a warning against interfaces on the implementor’s side just for mocking.
Read the guidance ↗Robert C. Martin · The Clean Architecture
The dependency rule, from 2012: source code dependencies point inwards, toward the parts that change least.
Read the post ↗TypeScript 3.8 · Type-only imports
Why import type leaves no trace at run time, and when it makes an import’s purpose
explicit.
A useful counterexample: a single small moduleWhen there are no arrows to manage
A script that requests one reset through one provider can import the client directly. With one module and one provider, there’s no second side for an arrow to protect.
08 / The parts to watch
Arrows are easy to draw and easy to break.
These are the places it still goes wrong.
Flipping an import doesn’t flip the calls
After the move, resets still calls smtp.send. Direction is about
who has to know about whom, not who runs first.
An interface for everything is ceremony
Go’s guidance: “Do not define interfaces on the implementor side of an API ‘for mocking’.” Add an interface where a consumer needs one, not beside every struct.
The contract can leak the provider
A Mailer whose send takes SMTP headers points the arrow inward and
the meaning outward. Name what the reset code needs, in its own words.
TypeScript won’t stop a cycle
The cycle version compiles. Go refuses it; TypeScript leaves it to you, so a lint rule or a check like the lesson’s import reader has to catch it.
Type-only imports still count
import type vanishes at run time, but the file still has to change when the type
does. For direction, it’s an arrow like any other.
Direction decays without a check
One convenient import from a feature into the design system undoes the rule. Check it in CI, the way the lesson’s test reads the imports.
09 / Make the call
What would you have to change tomorrow?
Give both versions a plausible change and follow the work it creates.
| The change | Provider’s type | Interface in resets |
|---|---|---|
| One provider, one small app | Simplest. | An interface only one type implements. |
| Switch email providers | Edit and re-test resets. | Add an adapter. |
| Rename what the SMTP module exports | resets changes. | smtp and main change. |
| Templates need the reset request type | A cycle. | The adapter imports it from resets. |
| Test resets without the SMTP module | Imports it anyway. | resets imports nothing. |
Move the interface to the code that uses it when a volatile part and a steady part meet. A second provider, or a template that needs the core’s types, is the moment.
Import the concrete type when there’s one implementation and no other side to protect.
The question I’d leave beside the code is: when this file changes, which other files have to change too, and should they?
10 / Take the idea with you
Explain the loop without saying “dependency inversion.”
“The reset code had to import the email module just to name the mailer’s type, so every provider change reached it, and when the templates needed the reset type, the two imported each other. We let the reset code say what it needs, and the email side imports that.” In a review, the words are dependency direction, dependency inversion, and import cycle.
Before moving on, jot down why the provider change reached the reset code, why the calls didn’t flip, and one import in your own code that points from a steady part to a changing one.
Connections to follow nextRelated lessons
- Dependency injection decides who supplies the mailer; this lesson decides which file owns its type.
- Inversion of control decides who calls whom, which is a separate question from who imports whom.
- Hexagonal / ports and adapters applies the same direction to a whole application’s boundary.