Case: Detecting Low Cohesion During Feature Development
1. Problem
A Rails application supports several ways to add a member to a project:
REST API
GraphQL mutation
CSV import
external directory sync
invitation acceptance
admin interface
Each entry point creates the membership directly.
The REST endpoint:
class ProjectMembersController < ApplicationController
def create
membership = project.memberships.create!(
user: User.find(params[:user_id])
)
render json: membership
end
end
The GraphQL mutation:
class Mutations::AddProjectMember < BaseMutation
def resolve(project_id:, user_id:)
project = Project.find(project_id)
{
membership: project.memberships.create!(
user: User.find(user_id)
)
}
end
end
The import service:
class BulkMemberImport
def import(project, rows)
rows.each do |row|
project.memberships.create!(
user: User.find_by!(email: row.fetch("email"))
)
end
end
end
The synchronization job and invitation flow perform the same write through their own paths.
A new requirement is introduced:
Archived projects must not accept new members.
The requirement is small.
The implementation is not.
8 files changed, 46 insertions(+), 11 deletions(-)
Every entry point receives another version of the same decision:
raise ProjectArchived if project.archived?
The REST endpoint checks it.
The GraphQL mutation checks it.
The importer checks it.
The synchronization job checks it.
The invitation flow checks it.
The frontend also hides the button:
{!project.archived && (
<AddMemberButton projectId={project.id} />
)}
The feature works.
The tests pass.
But one business decision now exists independently across several modules.
2. What broke
The specification describes one responsibility:
project membership
└─ archived projects reject new members
The code represents it as several unrelated decisions:
ProjectMembersController
└─ checks archived state
└─ creates membership
AddProjectMember mutation
└─ checks archived state
└─ creates membership
BulkMemberImport
└─ checks archived state
└─ creates membership
SyncExternalMembersJob
└─ checks archived state
└─ creates membership
ProjectInvitation
└─ checks archived state
└─ creates membership
No implementation boundary owns the operation:
admit a member to a project
This is low cohesion.
The code is grouped around delivery mechanisms:
controllers
graphql
jobs
imports
models
But the business capability is distributed between them.
Changing one membership rule requires finding and modifying every path that happens to create a membership.
This creates change amplification:
one acceptance criterion
↓
five independent decision sites
↓
eight modified files
↓
multiple opportunities to miss a path
The number of files alone is not the problem.
The frontend, API adapters, domain implementation, and tests may reasonably live in different files.
The problem is that several modules independently decide whether the operation is allowed.
Each decision site can drift without the others.
3. Typical solution
The engineer searches for membership creation:
rg 'memberships\.create|ProjectMembership\.create' app
They find the known entry points and patch each one:
raise ProjectArchived if project.archived?
A reviewer may notice the duplication:
This rule should probably live in one place.
The code is then extracted into a service:
module ProjectMembership
class AddMember
def self.call(project:, user:)
raise ProjectArchived if project.archived?
project.memberships.create!(user:)
end
end
end
Each entry point is expected to delegate to it:
ProjectMembership::AddMember.call(
project:,
user:
)
This improves the implementation.
But the repository still cannot answer:
Did every membership creation path migrate?
Does any entry point still write directly?
Will a future feature bypass this service?
Does this class own one coherent capability,
or has it merely become another generic service object?
A reviewer can search for direct writes.
A linter can forbid selected calls.
A code ownership rule can require approval.
A large-diff warning can report that eight files changed.
Each mechanism detects part of the problem.
None of them connects the evidence to the original claim:
Membership admission has one coherent implementation boundary.
4. Coherence solution
The behavioral requirement and the cohesion requirement are represented together:
coherence_slice! {
changelist "reject-members-for-archived-projects" {
spec "product/project-membership" {
title: "Project membership"
level: System
status: Active
links {
constrained_by "quality/project-membership-cohesion"
}
ac "rejects-members-for-archived-projects" {
title: "Archived projects reject new members"
intent: "No production path can add a member to an archived project"
risk: High
concerns: [Correctness, Maintainability]
links {
implemented_by file "app/domain/project_membership/add_member.rb"
verified_by test "bundle exec rspec spec/domain/project_membership/add_member_spec.rb"
verified_by feature "features/project_membership.feature"
}
}
}
context {
spec "quality/project-membership-cohesion" {
title: "Project membership cohesion"
level: Module
status: Active
ac "membership-admission-has-one-owner" {
title: "Membership admission has one implementation owner"
intent: "All production paths delegate membership admission decisions to one cohesive domain boundary"
risk: High
concerns: [Maintainability, Correctness]
links {
verified_by test "bin/check-project-membership-cohesion"
}
}
ac "membership-writes-pass-through-owner" {
title: "Membership writes pass through the domain boundary"
intent: "Adapters do not create project memberships directly"
risk: High
concerns: [Maintainability, Correctness]
links {
verified_by test "bin/check-project-membership-write-paths"
}
}
}
}
}
}
The verifier combines the specification graph with the SCIP code graph.
Before the change, it discovers:
product/project-membership
└─ rejects-members-for-archived-projects
├─ decision sites: 5
├─ direct membership writes: 5
├─ production entry points: 5
└─ cohesive implementation owners: 0
It can show the affected code surface:
Archived projects reject new members
├─ app/controllers/project_members_controller.rb
├─ app/graphql/mutations/add_project_member.rb
├─ app/services/bulk_member_import.rb
├─ app/jobs/sync_external_members_job.rb
├─ app/models/project_invitation.rb
├─ app/policies/project_policy.rb
├─ app/frontend/components/AddMemberButton.tsx
└─ spec/requests/project_members_spec.rb
The report does not conclude:
Eight files are too many.
It reports a more specific problem:
quality/project-membership-cohesion
└─ membership-admission-has-one-owner
Five production paths independently decide whether
a project member may be added.
No cohesive implementation owner was found.
After the responsibility is centralized, the code graph becomes:
REST controller ───────────────┐
GraphQL mutation ──────────────┤
CSV import ────────────────────┤
directory synchronization ────┼─→ ProjectMembership::AddMember
invitation acceptance ─────────┘ │
├─ checks project state
└─ creates membership
The verifier now reports:
membership admission
├─ production entry points: 5
├─ implementation owners: 1
├─ policy decision sites: 1
├─ direct writes outside owner: 0
└─ behavioral verification: passed
The engineer still has several valid choices:
- centralize the responsibility behind one domain boundary;
- split the operation if the entry points genuinely implement different policies;
- revise the cohesion specification;
- record a scoped exception for a deliberate direct path.
Coherence does not require one class, one function, or one file.
It verifies that one business responsibility maps to a coherent region of the code graph.
A tiny requirement producing a large diff is no longer merely inconvenient.
It becomes evidence that the implementation may not preserve the shape of the responsibility it implements.