skip to content

A team breaks a 3,000-line god class into several files using C# partial classes, Ruby class reopening or Objective-C categories, without moving a single member out of the type. Has cohesion improved? Explain what each mechanism actually changes.

level: seniorimportance: should knowfreq 30%

answer

  1. cohesion = member-to-field edges, not file boundaries
  2. partial = compile-time concatenation, split by origin
  3. Ruby reopening = load-order dependent member set
  4. categories collide silently; Swift @retroactive
  5. Rust orphan rule = one impl per (trait, type), program-wide

basics

~20 s

No. Cohesion is a property of the member set and the state those members touch; all three mechanisms merge back into one type with one field set. C# partial is a compile-time text split, Ruby reopening makes the member list load-order dependent, and Objective-C categories can collide with no diagnostic.

solid answer

~60 s

File layout is not a cohesion metric. All three mechanisms produce one type with one set of fields, so every method-to-field edge survives the split unchanged. - **C# `partial`**: purely compile-time concatenation. Its designed use is separating generated code from hand-written code - a split by *origin*, not by responsibility. Tooling still sees one class; reviewers lose the ability to see the whole type at once. - **Ruby reopening**: runtime and global. Any later-loaded file can add or replace members, so the class's member set depends on load order and cannot be enumerated statically. A cohesion problem becomes a hidden-coupling problem. - **Objective-C categories**: linked in from other binaries; two categories defining the same selector give an undefined winner with no error. Swift's descendant now requires `@retroactive` on cross-module protocol conformance for the same reason. - **Rust** allows many `impl` blocks but the orphan rule keeps them inside the owning crate, so a split can never become someone else's coupling channel. Measure cohesion on which methods touch which fields; extracting a field cluster into a collaborator is what moves that.

code

csharp · 9 lines
csharp
// Invoice.Totals.cs
public partial class Invoice { 
    decimal Subtotal() => lines.Sum(l => l.Amount);
}
// Invoice.Persistence.cs
public partial class Invoice {
    private List<Line> lines;          // the shared field lives here
    void Load() { lines = repo.Fetch(id); }
}

go deeper

for a junior

Say no, and give the reason: the parts merge into one type with one field set, so nothing about the members changed.

for a middle

Distinguish presentation from structure and name the intended use of partial classes (generated versus hand-written code).

for a senior

Compare the mechanisms by blast radius: compile-time concatenation, runtime global mutation with load-order dependence, and cross-binary category collisions, and say which of these makes the design worse rather than merely unchanged.

for a principal

Take a position on which mechanisms a codebase should permit at all, and describe the staged refactor - split by field cluster, verify disjointness, promote to collaborators - together with what you would measure to prove it worked.

## The claim under test "We split the god class into six files, so it is now cohesive." This confuses two different things: the *presentation* of a type (how its source is laid out for readers and version control) and its *structure* (which members exist, which state they share, and which reasons cause them to change). Cohesion is structural. A common operationalisation is to look at the class's fields and ask, for each pair of methods, whether they touch any field in common. A god class is one where that relation partitions into several disjoint clusters - a group of methods around the persistence fields, another around the formatting fields, another around the cache fields. Those clusters are the classes that want to exist. None of the three mechanisms in the question touches a single edge in that graph. ## What each mechanism really does **C# partial classes.** The compiler concatenates the parts before doing anything else; there is exactly one type in the assembly, with one field list and one member list. The feature was introduced for a specific and legitimate reason: keeping designer- or tool-generated members out of the file a human edits, so regeneration never clobbers hand-written code. That is a split by *origin*, and it genuinely reduces merge conflicts. It does not reduce responsibilities. A reviewer opening one part cannot see whether a method in another part also writes the field being modified - so the split can actively make the coupling harder to see. **Ruby class reopening.** Ruby classes are open at runtime: any file, at any point during load, can reopen `class Invoice` and add or replace methods. Splitting a class this way changes the nature of the problem rather than its size. The member set is no longer knowable from any one place, it depends on which files were required and in what order, and a later definition silently replaces an earlier one with the same name. Python's equivalent (assigning to a class attribute after definition) and JavaScript's prototype assignment have the same character. A design that was merely too big becomes one whose shape cannot be determined statically - the cohesion question becomes unanswerable, which is not the same as answered well. **Objective-C categories.** A category adds methods to an existing class from another file, and crucially from another compiled library. If two categories define the same selector, the winner is whichever loads last, and the runtime emits no diagnostic. This is why Apple's guidance is to prefix category method names. Swift inherited the idea as extensions and hardened it: extensions within a module are a fine organisational tool, but adding a *protocol conformance* to a type you do not own, for a protocol you do not own, is global by nature. Two modules doing it produce a link-time coin flip, so Swift now asks you to write `@retroactive` to say you accept the risk. **Rust as the contrast.** Rust lets you write as many `impl` blocks for a type as you like, in as many files as you like inside the crate. But the orphan rule refuses `impl SomeoneElsesTrait for SomeoneElsesType`, guaranteeing that for any (trait, type) pair the whole program has exactly one implementation - coherence. The price is newtype wrappers when you genuinely need the missing combination. The design point is that a code-organisation feature and a global-extension feature are different features, and the languages that conflated them pay for it with collisions. ## Where the split *is* useful Generated versus hand-written code, platform-specific parts of one type, and test-only additions in a language where that is safe. All of these are splits along an axis other than responsibility, and none is claimed to improve cohesion. It is also legitimate as a *transitional* step: split by cluster into parts, verify that each part touches a disjoint set of fields, then promote each part into a real collaborator. The mistake is stopping at step one and declaring victory. ## The verdict to give in an interview Cohesion is unchanged; readability of a single file improved; in Ruby and Objective-C, coupling got worse because the type became open to definition from places it never names. The real fix is to extract the field cluster along with the methods that touch it into a new type the original delegates to - after which the original's remaining methods share state again and the metric moves for a reason.

  • What would you measure to show whether the split changed anything?
    Build the method-to-field relation for the type and look for disjoint clusters: for each pair of methods, do they touch a common field? A file split leaves that relation identical, so any lack-of-cohesion metric derived from it reports the same value before and after. The number that should move after a real extraction is the count of clusters in the original type, which drops as each cluster leaves with its fields.
  • When is splitting a type across files the right call?
    When the axis of the split is not responsibility: generated versus hand-written code, platform-specific members, or a staged refactor where each part is verified to touch a disjoint field set before being promoted to its own type. In those cases nobody claims cohesion improved - the claim is fewer merge conflicts or a safe regeneration boundary, and that claim is true.

saying these in an interview costs you the question

  • Treating file count or file length as a cohesion metric
  • Believing partial classes create separate types at runtime
  • Assuming Ruby reopening is just a syntax for splitting a file, ignoring load order and silent replacement
  • Saying Swift extensions and Rust impl blocks are equivalent - the orphan rule is the whole difference
  • Claiming a category collision is a compile error

context