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/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.
// 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`;
}
// 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.
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.
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.tsand 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
clockanddecimalHours, instead oftrueandfalse.- Parts you can reuse alone
payrollCsvtakes 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.
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.
One day’s minutes
And a callback when they change.
The week’s minutes
And a slot for whatever goes beside the total.
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.
// 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.
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.
| Where you’ve seen it | What it shows | Narrower |
|---|---|---|
<Avatar user={user} /> showing a name and photo | Stamp coupling | name and src props |
formatDate(date, true) | Control coupling | Two functions, each named for its job |
A config object imported and read everywhere | Common coupling | Pass the value to the code that needs it |
Reading a library’s _internal field | Content coupling | Its public API, or a request for one |
utils.ts that formats, validates, and sends email | Low cohesion | Files 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.
| The change | Grab-bag | Grouped |
|---|---|---|
| Weekly overtime | The setting, overtimeMinutes, and reminderText. | overtime.ts only. |
| Test the overtime rule | Build a Timesheet. | Pass five numbers. |
| Payroll wants one decimal place | One setting. | One function. |
| Explain the rule to a new teammate | Three places to point at. | One file. |
| Read the whole report in one sitting | One 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
- Dependency direction decides which way the imports that remain should point.
- Delegation narrows what callers can reach on an object.
- Pure functions and side effects is data coupling taken all the way: everything a function uses arrives as a parameter.
- Modular monolith applies the same questions to whole modules and the data they own.