Draining the kitchen sink

Why broad domain modules become dumping grounds for unrelated code, and how decoupling the consumption site keeps consumers minimal and resilient.

By Eyal Ellenbogen October 8, 2026 5 min read

Naming a shared abstraction after a broad domain concept like "auth", "billing", or "workspace" creates a natural gravity well. It usually begins with a couple of clear operations like signing in or checking session status. Over time, that same container becomes our default destination for user profile queries, role parsing, token refresh lifecycles, and permission checks.

This rarely stems from architectural carelessness. It is driven by the path of least resistance, where creating a brand new boundary, deciding where it should live, and wiring its lifecycle feels heavy, while appending another method to a class that is already open in our editor takes seconds. If that catch-all bucket didn't exist, we would naturally write a focused helper. But once the container is there, convenience wins.

In Angular, this dynamic materializes as an expanding service:

@Injectable({ providedIn: 'root' })
export class AuthService {
	readonly user: Signal<User | null>
	login(credentials: Credentials): Promise<void>
	logout(): Promise<void>
	refreshToken(): Promise<void>
	hasPermission(permission: string): boolean
}

The consequence is that concerns with completely different lifecycles and consumer sets end up bound to the same injectable surface.

Different concerns, shared surface

Whether an abstraction manages authentication, billing, or workspace settings, expanding modules almost always conflate three fundamentally different kinds of work:

  • Commands with side effects: operations that initiate network requests, mutate remote data, or trigger route transitions (like login or logout). Yet even within this category, callers rarely overlap. An unauthenticated login screen has no reason to import session teardown.
  • Core state: signals or observable data representing the primary entity or model (like the active user), consumed by navigational shells, headers, and profile displays.
  • Derived computations: pure evaluations that read existing state without triggering actions (like hasPermission or quota checks), consumed by feature views to enable actions or render conditional UI.

When these coexist on a single service, consuming views inherit dependencies they do not need, running counter to building components with clear responsibilities. A feature view such as a ReportsView might only need to verify whether the active account can generate an export:

@Component({
	selector: 'reports-view',
	template: `
		<section class="reports">
			<h1>Reports</h1>
			@if (canExport()) {
				<button (click)="exportReport()">Export</button>
			}
		</section>
	`,
})
export class ReportsView {
	private readonly auth = inject(AuthService)
	protected readonly canExport = computed(() => this.auth.hasPermission('reports:export'))

	protected exportReport(): void {
		// report generation logic
	}
}

The view requires a simple boolean evaluation, yet its contract is coupled to full session orchestration, including token refresh mechanics and credential submission.

Bundling setup, isolating consumption

We could scatter half a dozen loose InjectionTokens across root configs, but that solves a coupling problem by creating an ergonomics problem. A cleaner path keeps setup unified at the edge while exposing focused helpers for callers:

export function provideAuth(config: AuthConfig): EnvironmentProviders {
	return makeEnvironmentProviders([
		/* private tokens and session state wired here */
	])
}

export const injectUser = () => inject(SESSION_USER)
export const injectLogin = () => inject(LOGIN_COMMAND)

export function injectHasPermission(permission: string): Signal<boolean> {
	const user = inject(SESSION_USER)
	return computed(() => user()?.roles.includes(permission) ?? false)
}

The application root wires the feature once with provideAuth(config), mirroring the idiom established by Angular APIs such as provideRouter.

Crucially, this does not require declaring an avalanche of low-level tokens. The internal implementation can still use focused classes or services, such as an internal SessionStore and an AuthClient. What changes is exposure: provideAuth registers them internally, while the exported inject* functions project or adapt only the narrow slices that consumers need. The internal plumbing stays encapsulated inside the provider.

Consumers then declare only the slice of the contract they actually evaluate:

export class ReportsView {
	protected readonly canExport = injectHasPermission('reports:export')
}

By decoupling consumption, the view no longer holds references to session lifecycle methods.

The command side reaps the exact same benefit. An unauthenticated dialog that initiates a sign-in only needs a trigger, not session teardown or permissions:

@Component({
	selector: 'login-dialog',
	template: `<button (click)="signIn()">Sign In</button>`,
})
export class LoginDialog {
	private readonly login = injectLogin()
	protected credentials: Credentials = { username: '', password: '' }

	protected signIn(): void {
		this.login(this.credentials)
	}
}

Neither component knows the other exists. ReportsView depends on a derived evaluation; LoginDialog depends on an action invocation. They cannot accidentally cross-contaminate each other or force shared refactors.

This is the Interface Segregation Principle (ISP) applied directly to UI architecture, where clients should not be forced to depend on methods they do not use. In modern frontend development, ISP does not require engineering vast class hierarchies or splitting abstract interfaces. It simply means shaping consumption entry points around what each caller actually touches.

The testing dividend

Test setup should match the real dependency, not the whole neighborhood it lives in. As explored in testing a feature without the host, when consumption is decoupled from the kitchen sink, tests only satisfy the specific state a view actually reads, without maintaining mock factories for methods it never touches.

The same balance in other ecosystems

In React, wrapping authentication in a single useAuth() context creates the same coupling, and the framework makes the cost visible as every consumer re-renders when any field on the value changes, whether or not it reads that field. A ReportsView that only checks canExport is invalidated by a token refresh.

We can fix it with the same shape: wrap setup in one provider, split the internal contexts, and expose narrow hooks (useUser(), useLogin(), useHasPermission()). The render behavior improves as a side effect of the boundary improving, not as the goal.

Keeping the boundary intentional

Fine-grained consumption is about protecting call sites, not pursuing micro-abstractions for their own sake:

  • Don't split things that belong together: If a small utility has a cohesive job and all its callers use the same parts, leave it alone. Split only when different parts of your app start calling it for completely different reasons.
  • Encapsulated plumbing: how we wire a capability together internally should remain an implementation detail. Callers only care about the narrow contract they consume.
  • Resisting the open file: convenience in the moment is the strongest architectural force in a codebase. The next time we catch ourselves adding a method simply because a file is already open, we should pause and ask whether it deserves a dedicated boundary instead.

When we separate feature setup from caller consumption, we keep contracts minimal, test setups trivial, and our codebase resilient to everyday change.