← Concepts & practices
Concept Design principles and language mechanisms

Coupling & cohesion

Put together what changes together.

You already know the “small” change that ends up touching five files. Let’s follow a timesheet app whose helpers all live in one file, until payroll moves overtime from daily to weekly and a reminder goes out promising overtime “after 40:00 a day”.

TypeScriptGo One weekly report, two layouts, two languages.

01 / The idea

One file of timesheet helpers is a fair start.

You’re building a team timesheet app. Every Friday it sends payroll a CSV and each person a reminder. timesheet-utils.ts has everything: formatHours, overtimeMinutes, payrollCsv, and reminderText, all reading one settings object. One file to open, every helper in reach, and the weekly report is right.

Read the first versionTypeScript · the version this lesson starts from
grabbag
// grabbag/settings.ts
// Settings every helper reads. Change a value here and every reader sees the new one.
export const settings = {
	overtimeAfterMinutes: 8 * 60,
	csvDecimals: 2,
	workdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
};

// grabbag/timesheet-utils.ts
// The first version: every timesheet helper in one file, reading one settings object.
import { settings } from './settings.ts';

export type Entry = { day: string; minutes: number };

export class Timesheet {
	readonly employeeId: string;
	readonly name: string;
	_entries: Entry[];

	constructor(employeeId: string, name: string, entries: Entry[]) {
		this.employeeId = employeeId;
		this.name = name;
		this._entries = entries;
	}
}

// forCsv picks between two jobs: payroll's decimal hours and a person's hours and minutes.
export function formatHours(minutes: number, forCsv: boolean): string {
	if (forCsv) return (minutes / 60).toFixed(settings.csvDecimals);
	return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
}

export function overtimeMinutes(sheet: Timesheet): number {
	let overtime = 0;
	for (const day of settings.workdays) {
		const worked = sheet._entries
			.filter((entry) => entry.day === day)
			.reduce((sum, entry) => sum + entry.minutes, 0);
		overtime += Math.max(0, worked - settings.overtimeAfterMinutes);
	}
	return overtime;
}

export function payrollCsv(sheets: Timesheet[]): string {
	const lines = ['employee_id,regular_hours,overtime_hours'];
	for (const sheet of sheets) {
		const total = sheet._entries.reduce((sum, entry) => sum + entry.minutes, 0);
		const overtime = overtimeMinutes(sheet);
		lines.push(
			`${sheet.employeeId},${formatHours(total - overtime, true)},${formatHours(overtime, true)}`
		);
	}
	return lines.join('\n');
}

export function reminderText(sheet: Timesheet): string {
	const missing = settings.workdays.filter(
		(day) => !sheet._entries.some((entry) => entry.day === day && entry.minutes > 0)
	);
	const ask = missing.length ? `Log hours for ${missing.join(', ')}. ` : '';
	const rule = `after ${formatHours(settings.overtimeAfterMinutes, false)} a day`;
	return `${sheet.name} · ${ask}Overtime so far: ${formatHours(overtimeMinutes(sheet), false)} (${rule}).`;
}

export function weekReport(sheets: Timesheet[]): { csv: string; reminders: string[] } {
	return { csv: payrollCsv(sheets), reminders: sheets.map(reminderText) };
}

Go’s first version has a package-level Settings variable and the same four functions. Both languages meet again in the grouped layout in section 02.

Then payroll switches to overtime after 40 hours a week. The limit is in settings, the calculation is in overtimeMinutes, and the words “a day” are written inside reminderText. The first attempt changes two of the three. Types check, the CSV is right, and the reminders are wrong.

Coupling is how much one piece of code depends on the details of another: the more it knows, the more of the other’s changes reach it. Cohesion is how well the code in one place belongs together, which in practice means it changes for the same reason. You want each change to land in one cohesive place, and the places to know as little about each other as they can. The two words come from Larry Constantine’s structured design work, and reviewers still reach for them first.

Section 05 builds a week editor whose components know only what they show, in React and Svelte.

02 / See the shape

Group by reason to change, and pass plain values between groups.

The basic form is hours and overtime: minutes in, minutes or text out, with the overtime rule next to its description. In the wild adds payroll and reminders, which get numbers and strings instead of timesheets. At the call site is the week report that connects them, run beside the grab-bag.

Both layouts, in both languages, produce the same CSV and reminders, before and after payroll’s switch to weekly overtime.

Hours and overtime, grouped by reason to change. Each function takes minutes, and the overtime rule sits next to its description.

TypeScriptReading
timesheet
// grouped/hours.ts
// Hours: turning entries into minutes per workday, and showing minutes two ways.
export const WORKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];

export type Entry = { day: string; minutes: number };

export function dailyMinutes(entries: readonly Entry[]): number[] {
	return WORKDAYS.map((day) =>
		entries.filter((entry) => entry.day === day).reduce((sum, entry) => sum + entry.minutes, 0)
	);
}

export function clock(minutes: number): string {
	return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
}

export function decimalHours(minutes: number): string {
	return (minutes / 60).toFixed(2);
}

// grouped/overtime.ts
// Overtime: the rule and how it's described. When payroll changes the rule, this file changes.
import { clock } from './hours.ts';

const LIMIT_PER_DAY = 8 * 60;

export function overtimeMinutes(daily: readonly number[]): number {
	return daily.reduce((sum, minutes) => sum + Math.max(0, minutes - LIMIT_PER_DAY), 0);
}

export function overtimeRule(): string {
	return `after ${clock(LIMIT_PER_DAY)} a day`;
}
GoAlongside
timesheet.go
// Hours and overtime, grouped by the reason each would change. They take minutes, not timesheets.
var Workdays = []string{"Mon", "Tue", "Wed", "Thu", "Fri"}

func DailyMinutes(entries []Entry) []int {
	daily := make([]int, len(Workdays))
	for _, entry := range entries {
		if index := slices.Index(Workdays, entry.Day); index >= 0 {
			daily[index] += entry.Minutes
		}
	}
	return daily
}

func Clock(minutes int) string        { return fmt.Sprintf("%d:%02d", minutes/60, minutes%60) }
func DecimalHours(minutes int) string { return fmt.Sprintf("%.2f", float64(minutes)/60) }

const limitPerDay = 8 * 60

func DailyOvertime(daily []int) int {
	overtime := 0
	for _, minutes := range daily {
		overtime += max(0, minutes-limitPerDay)
	}
	return overtime
}

func OvertimeRule() string { return "after " + Clock(limitPerDay) + " a day" }
Reading the TypeScriptShared objects and conventions

settings is an exported object, so any file can read it, and any file can change it. _entries is public; the underscore only asks politely. Nothing stops payrollCsv reading it.

clock and decimalHours replace formatHours’s flag with two names. Each caller says which job it wants.

Reading the GoPackage variables and plain parameters

Settings is a package-level variable, and the test changes it to show the reminder picking up the new limit. Go has no underscore convention; within one package, every field is reachable.

DailyOvertime(daily []int) and Reminder(name, missing, overtime, rule) take plain values, so their tests don’t build a Timesheet.

03 / Follow one change

Make payroll’s change in both layouts, and count what moved.

Five steps. Each layout exists before and after the weekly change, and the page compares them declaration by declaration; the tags are read from each function’s parameters and what it touches. Before steps 2 and 4, guess how many declarations change.

In Try it, change Ben’s hours, the rule, and the layout.

Coupling & cohesion

How far does one change reach?

One file, every helper. settings.ts: settings. timesheet-utils.ts: Entry, Timesheet, formatHours: common: settings.csvDecimals; control: forCsv flag, overtimeMinutes: common: settings.workdays; common: settings.overtimeAfterMinutes; content: reads _entries; stamp: uses _entries of Timesheet, payrollCsv: content: reads _entries; stamp: uses employeeId, _entries of Timesheet, reminderText: common: settings.workdays; common: settings.overtimeAfterMinutes; content: reads _entries; stamp: uses name, _entries of Timesheet, weekReport: passes Timesheets on. Output: employee_id,regular_hours,overtime_hours / E-104,31.50,1.50 / E-221,38.00,6.00 / Ana · Log hours for Fri. Overtime so far: 1:30 (after 8:00 a day). / Ben · Overtime so far: 6:00 (after 8:00 a day).. 2 files, 1 import. Every function reads the shared settings or the whole Timesheet, and formatHours does two jobs behind a flag.

01/ 05
Read the grab-bag’s couplings

Everything can reach everything.

Shared settings, a flag, and whole timesheets passed to functions that need one field.

Reduced motion: choose a scene to see its completed state.

Read this scene

Shared settings, a flag, and whole timesheets passed to functions that need one field.

One file, every helper. settings.ts: settings. timesheet-utils.ts: Entry, Timesheet, formatHours: common: settings.csvDecimals; control: forCsv flag, overtimeMinutes: common: settings.workdays; common: settings.overtimeAfterMinutes; content: reads _entries; stamp: uses _entries of Timesheet, payrollCsv: content: reads _entries; stamp: uses employeeId, _entries of Timesheet, reminderText: common: settings.workdays; common: settings.overtimeAfterMinutes; content: reads _entries; stamp: uses name, _entries of Timesheet, weekReport: passes Timesheets on. Output: employee_id,regular_hours,overtime_hours / E-104,31.50,1.50 / E-221,38.00,6.00 / Ana · Log hours for Fri. Overtime so far: 1:30 (after 8:00 a day). / Ben · Overtime so far: 6:00 (after 8:00 a day).. 2 files, 1 import. Every function reads the shared settings or the whole Timesheet, and formatHours does two jobs behind a flag.

Watch restarts when you return. Step through keeps your selected step. Try it starts with Ben’s week in the grab-bag each time you open it.

What narrow coupling and cohesive files buy 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.

Changes that land in one place
Weekly overtime changed overtime.ts and nothing else.
Tests with plain values
overtimeMinutes([600, 480, 0, 0, 0]) is 120, with no timesheet to build.
No setting to reinterpret
The limit is private to the file that uses it, so its meaning can’t drift elsewhere.
Names that say the job
clock and decimalHours, instead of true and false.
Parts you can reuse alone
payrollCsv takes rows, so a contractor import can use it without timesheets.

The review words are coupling and cohesion, and the kinds of coupling the scene tags: data coupling for plain values, stamp coupling for a whole Timesheet passed to a function that uses one field, control coupling for the forCsv flag, common coupling for the shared settings, and content coupling for reading _entries. “One reason to change” is how the single responsibility principle phrases cohesion. Section 08 covers what they cost.

04 / Try a decision

Half of payroll’s change.

The first attempt at weekly overtime changed the setting and the calculation. The code is in half-changed/, and the lesson’s tests pin what happens.

What does Ben’s reminder say on Friday?

Payroll’s switch ships as a two-line change to the grab-bag: settings.overtimeAfterMinutes becomes 40 hours, and overtimeMinutes now subtracts it from the week’s total. Nothing else changes, the types check, and the payroll CSV is correct. Ben worked 44 hours.

05 / Give it a real job

A week editor whose parts know only what they show.

In the real app, people fill in their week in a grid: one field per day, a total, and an overtime badge when they pass the limit. Each part should be testable and reusable without dragging the whole employee or week along.

DayCell

One day’s minutes

And a callback when they change.

WeekTotals

The week’s minutes

And a slot for whatever goes beside the total.

WeekEditor

The week itself

It holds the state and decides what goes in the slot.

The example leaves out saving to the server, approvals, and weekend work.

Build UIs?Every prop you pass is something the component now knows, and every flag is a decision you made for it.

Where it already is in your components

React’s memo reference makes the same point for a performance reason: “A better way to minimize props changes is to make sure the component accepts the minimum necessary information in its props. For example, it could accept individual values instead of a whole object”. The textbook badge takes name and avatarUrl, not the employee with their pay rate and manager.

In Svelte, a boolean prop that switches a component’s content can often be a snippet instead. Its docs: “snippets are values just like any other. As such, they can be passed to components as props.”

When you have to own it

Now it’s the week editor. DayCell gets one day’s minutes, so it can be tested with a number. WeekTotals gets the week’s minutes and a children slot; the editor puts the overtime badge in it, so the totals never grow a showOvertime flag or learn the overtime rule.

Only WeekEditor holds the week. If payroll changes the rule, the helper and the editor change; the cell and the totals don’t.

week-hours.ts
// What the week editor's components share: plain minutes in, text or minutes out.
export const WORKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
export const LIMIT_PER_DAY = 8 * 60;

export function clock(minutes: number): string {
	return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
}

export function overtimeMinutes(daily: readonly number[], limitPerDay = LIMIT_PER_DAY): number {
	return daily.reduce((sum, minutes) => sum + Math.max(0, minutes - limitPerDay), 0);
}

// "7:30" becomes 450. Anything else, or more than 24 hours, is null.
export function parseClock(text: string): number | null {
	const match = text.trim().match(/^(\d{1,2}):([0-5]\d)$/);
	if (!match) return null;
	const minutes = Number(match[1]) * 60 + Number(match[2]);
	return minutes <= 24 * 60 ? minutes : null;
}

An employee badge that takes the name and avatar it shows, not the whole employee.

ReactAlready in your code
EmployeeBadge.tsx
import { memo } from 'react';

type Employee = {
	id: string;
	name: string;
	avatarUrl: string;
	email: string;
	hourlyRate: number;
	managerId: string;
};

// The badge takes the two values it shows. It doesn't know an Employee exists.
export const EmployeeBadge = memo(function EmployeeBadge({
	name,
	avatarUrl
}: {
	name: string;
	avatarUrl: string;
}) {
	return (
		<span className="badge">
			<img src={avatarUrl} alt="" width={24} height={24} />
			{name}
		</span>
	);
});

export function TimesheetHeader({ employee }: { employee: Employee }) {
	return (
		<header>
			<EmployeeBadge name={employee.name} avatarUrl={employee.avatarUrl} />
		</header>
	);
}

06 / Recognize it elsewhere

Ask what the code knows that it doesn’t use.

You’ve written all of these. For each one, find the narrower version.

Familiar code, the coupling it shows, and a narrower version
Where you’ve seen itWhat it showsNarrower
<Avatar user={user} /> showing a name and photoStamp couplingname and src props
formatDate(date, true)Control couplingTwo functions, each named for its job
A config object imported and read everywhereCommon couplingPass the value to the code that needs it
Reading a library’s _internal fieldContent couplingIts public API, or a request for one
utils.ts that formats, validates, and sends emailLow cohesionFiles grouped by what makes them change

The kinds are a checklist, not a ranking to memorize. Each asks the same question from a different angle: if the other side changes, will this code have to notice?

07 / Already in your toolbox

Your tools already nudge you toward narrow parts.

Three places to look. For each one, find what it asks you to pass, and what it asks you not to share.

React · memo, Minimizing props changes

Why a component that takes individual values re-renders less than one that takes a whole object.

Read the reference ↗

Svelte · Passing snippets to components

How a component takes content from its parent instead of a flag that picks it.

Read the docs ↗

Go Proverbs

“A little copying is better than a little dependency.” A short list with opinions about what to share.

Read the proverbs ↗
A useful counterexample: a script you run onceWhen one file is right

A script that converts last year’s timesheets for an audit has one reason to change: the audit. Everything in it belongs together, so one file is cohesive. Splitting it would add imports and nothing else.

08 / The parts to watch

Both ideas cut both ways.

These are the places it still goes wrong.

Coupling you can’t see is the expensive kind

The half change compiled. A shared setting whose meaning changed and a rule described in another function don’t show up as imports or type errors. An import you can see is the cheap kind.

Splitting too far is low cohesion too

If one change touches five tiny files, the code that changes together was split apart. Group by reason to change, not by the smallest possible unit.

A shared helper couples its callers

If payroll and reminders share one formatter, payroll asking for one decimal place changes the reminders. The Go proverb puts it bluntly: “A little copying is better than a little dependency.”

Destructuring doesn’t remove stamp coupling

function overtime({ entries }: Timesheet) still needs a Timesheet to call. Narrow the parameter type, not just the body.

Flags multiply

One boolean is two behaviors; two booleans are four, and each needs a test. When a flag picks what a function does, give each job its own name.

Grouping by kind isn’t cohesion

A formatters.ts holding every formatter changes for payroll, reminders, and UI reasons. Files grouped by technical kind look tidy and change for unrelated reasons.

09 / Make the call

What would you have to change tomorrow?

Give both layouts a plausible change and follow the work it creates.

How a change affects the grab-bag layout and the grouped layout
The changeGrab-bagGrouped
Weekly overtimeThe setting, overtimeMinutes, and reminderText.overtime.ts only.
Test the overtime ruleBuild a Timesheet.Pass five numbers.
Payroll wants one decimal placeOne setting.One function.
Explain the rule to a new teammateThree places to point at.One file.
Read the whole report in one sittingOne file.Five files and seven imports.

Group code by the reason it changes, and pass each part only the values it uses, once more than one rule or team is changing it. A payroll rule that changes on someone else’s schedule is the moment.

Keep one file while the code is small and changes for one reason.

The question I’d leave beside the code is: when this changes, what else has to change with it, and should it?

10 / Take the idea with you

Explain the wrong reminder without saying “coupling.”

“The overtime rule was spread across a shared setting, the calculation, and the reminder’s wording, so changing it meant finding all three, and we found two. Now the rule and its wording live in one file, and everything else gets the numbers it needs.” In a review, the words are coupling, cohesion, and common coupling.

Before moving on, jot down why the half change compiled, what the grouped layout costs, and one function in your own code that takes a whole object to use one field of it.

Connections to follow nextRelated lessons

Take the timesheets into your editor. Finish the half change, then split formatHours into two functions and count which declarations changed.

Back to Concepts & practices →