← Concepts & practices
Concept Design principles and language mechanisms

Dependency direction

Point the arrows at the code that matters.

You already pass the mailer in. Let’s follow password-reset code whose mailer is injected but whose type still comes from the SMTP module, until a provider change reaches the reset code and an email template turns the imports into a loop.

TypeScriptGo One password-reset flow, two implementations.

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
start/resets.ts
// 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.

TypeScriptReading
direction.ts
// 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'
			});
		}
	};
}
GoAlongside
direction.go
// 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.

Dependency direction

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.

01/ 05
Read the first version’s imports

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 smtp reaches smtp and main, not the reset code.
Core code with no imports
resets.ts imports 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 Mailer too; resets doesn’t change.
One file that knows everything
main is 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.

After the move, which way do the calls go?

Mailer moved into resets.ts, and smtp.ts now has import type { Email, Mailer } from './resets'. The import arrow between them points from smtp to resets. main.ts wires them, and then someone requests a reset.

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.

Design system

Imports nothing from features

It defines its own words: tones, text, dismiss.

Account feature

Imports the design system

It decides what the toast says after a reset.

App root

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.

ui/toast.ts
// 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.

ReactAlready in your code
ui/Banner.tsx
// 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.

Familiar imports, which way they point, and what that protects
Where you’ve seen itWhich way it pointsWhat it protects
A component library from npmYour app → the libraryThe library never changes because your app did
features/ and ui/ foldersFeatures → UIShared pieces stay reusable
Go database driversDrivers → database/sql/driverYour code uses database/sql, whichever driver is installed
A plugin systemPlugins → the host’s plugin typesThe host doesn’t change for each plugin
Shared types for a client and a serverBoth → the shared typesNeither 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.

Read the notes ↗
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.

How a change affects code typed by the provider and code that declares its own interface
The changeProvider’s typeInterface in resets
One provider, one small appSimplest.An interface only one type implements.
Switch email providersEdit and re-test resets.Add an adapter.
Rename what the SMTP module exportsresets changes.smtp and main change.
Templates need the reset request typeA cycle.The adapter imports it from resets.
Test resets without the SMTP moduleImports 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

Take the example into your editor. Add a postmark.ts adapter, run the import reader again, and confirm that nothing new points out of resets.ts.

Back to Concepts & practices →