Skip to main content
Mohammad Shehadeh — home (MSH monogram, letter M filled with the Palestinian flag)

Coupling Isn't Bad, Bad Coupling Is

Published on
5 min read

"Reduce coupling" is one of those phrases everyone repeats and nobody questions. But coupling isn't the villain. A function that calls another is coupled to it. A component that reads from a store is coupled to it. Software is coupling, parts leaning on parts to get anything done.

The real villain is careless coupling: things wired together for convenience, not because they belong together, so every change ripples further than it should. It never looks dangerous at first. That's how it sinks a codebase.

So the useful question isn't "is this coupled?" Everything is. It's "do these two things have a real reason to change together?"

The Real Question

If two things genuinely change for the same reason, coupling them is fine, even good. If they don't, you've created accidental coupling, and that's where the pain lives.

Three examples make this concrete.

The Giant Multi-Step Form

Almost every codebase grows one. A form starts simple, then every new field and rule gets piled into the same file until one component knows about everything.

mega-form.tsx
1// One file. Every step, every field, every rule. 1,200 lines and counting.
2const RegistrationForm = () => {
3  const [step, setStep] = useState(1);
4  const [name, setName] = useState("");
5  const [email, setEmail] = useState("");
6  const [company, setCompany] = useState("");
7  const [taxId, setTaxId] = useState("");
8  const [plan, setPlan] = useState("");
9  const [cardNumber, setCardNumber] = useState("");
10  // ...30 more fields
11
12  const validate = () => {
13      if (step === 1 && !name) return "Name required";
14      if (step === 1 && !email.includes("@")) return "Bad email";
15      if (step === 2 && plan === "business" && !taxId) return "Tax ID required";
16      if (step === 3 && cardNumber.length < 16) return "Invalid card";
17      // ...every rule for every step, all tangled together
18  };
19
20  return (
21      <form>
22          {step === 1 && (/* personal fields */)}
23          {step === 2 && (/* plan fields */)}
24          {step === 3 && (/* payment fields */)}
25      </form>
26  );
27};

No single line is wrong. But the payment logic now shares a file with the name field. The tax-ID rule knows about the plan, which knows about the step number. To fix step three's validation, you scroll past steps one and two to find it.

These steps never had a reason to share a file. They got glued together because adding one more if was the easy path every time.

The fix: let each step own its fields, validation, and UI. The parent just knows the order.

composed-form.tsx
1// The parent only orchestrates. It knows nothing about individual fields.
2const RegistrationForm = () => {
3  const [step, setStep] = useState(1);
4
5  const steps = [
6      <PersonalStep onNext={() => setStep(2)} />,
7      <PlanStep onNext={() => setStep(3)} onBack={() => setStep(1)} />,
8      <PaymentStep onBack={() => setStep(2)} />,
9  ];
10
11  return <form>{steps[step - 1]}</form>;
12};
13
14// Each step owns its fields and its own validation, nothing else.
15const PlanStep = ({ onNext, onBack }: StepProps) => {
16  // plan + tax-id logic lives here and only here
17};

Now the payment step changes without anyone touching the personal step, and each piece is testable alone. The coupling that's left, the parent knowing the order, is the real kind. It has to exist.

Search That Knows Too Much

A search box should do one thing: take a query, return results. But it tends to sprout tentacles into filtering, sorting, URL state, analytics, and rendering.

overloaded-search.ts
1// The search function decides everything: how to query,
2// how to filter, how to sort, how to format, how to track.
3const search = (query: string) => {
4  const results = db.query(query);
5  const filtered = results.filter((r) => r.status === activeFilter);
6  const sorted = filtered.sort(currentSortFn);
7  analytics.track("search", { query, count: sorted.length });
8  updateUrl({ q: query, filter: activeFilter });
9  return sorted.map((r) => renderResultCard(r));
10};

This one function is now tied to the database shape, the filter, the sort state, the analytics SDK, the router, and the UI. Restyle a result card? Edit search. Add a filter? Edit search. Everything funnels through one overloaded bottleneck.

Split those jobs apart, querying, filtering, tracking, rendering, and each can change without disturbing the rest. Search goes back to doing one thing, and the callers compose the rest.

The Sprawling Config Object

The sneakiest one: a giant config blob with thirty-plus fields, each shaped a little differently because each was added by a different person on a different day.

inconsistent-config.ts
1const fieldConfig = {
2  name: { label: "Name", required: true, type: "text" },
3  email: { title: "Email", isRequired: "yes", inputType: "email" },
4  age: { label: "Age", required: 1, kind: "number", min: 18 },
5  bio: { displayName: "Bio", optional: false, widget: "textarea" },
6  country: { label: "Country", req: true, type: "select", opts: [...] },
7  // ...30 more, each invented its own keys and conventions
8};

Spot the chaos: required, isRequired, req, and optional are the same idea in four costumes. type, inputType, kind, and widget all describe the input. label, title, and displayName all name the field. Every reader has to memorize the dialect of each entry.

This is coupling at its most corrosive. There's no single shape to learn, so the cost doesn't grow with the number of fields, it grows with every inconsistency. You can't write one renderer or one type to cover them, because no two fields agree on what "required" looks like.

Why This Is So Harmful

An inconsistent shape turns every entry into a special case. The config stops being data and becomes a pile of exceptions, each one a thing you have to remember.

The fix isn't fewer fields, it's one shape every field obeys:

consistent-config.ts
1interface FieldConfig {
2  label: string;
3  type: "text" | "email" | "number" | "select" | "textarea";
4  required: boolean;
5  min?: number;
6  options?: string[];
7}
8
9const fieldConfig: Record<string, FieldConfig> = {
10  name: { label: "Name", type: "text", required: true },
11  email: { label: "Email", type: "email", required: true },
12  age: { label: "Age", type: "number", required: true, min: 18 },
13  bio: { label: "Bio", type: "textarea", required: false },
14  country: { label: "Country", type: "select", required: true, options: [...] },
15};

Still thirty fields, but only one shape to learn. One renderer handles all of them. One type catches mistakes. The thirty-first field costs nothing extra, because it speaks the same language as the rest. The coupling didn't vanish, it became consistent, and consistent coupling is cheap.

The Takeaway

Coupling isn't the enemy, accidental coupling is. The form, the search box, and the config all went wrong the same way: things got wired together for convenience, not because they belonged together.

So before you wire two things up, ask one question: do these really belong together, or am I just taking the easy path today? If they belong together, couple them, and keep it consistent. If they don't, give each piece its own corner. Your future self, reading this file next month, will thank you.

Related Articles

GET IN TOUCH

Let's work together

I build fast, accessible, and delightful digital experiences for the web. Whether you have a project in mind or just want to connect, I'd love to hear from you.

Get in touch

or reach out directly at hello@mohammadshehadeh.com