DEV Community

Cover image for Your Abstractions Are Lying to You | Every File You Create Is a Debt
Ayush Maurya
Ayush Maurya

Posted on

Your Abstractions Are Lying to You | Every File You Create Is a Debt

There's a book from 2018 called A Philosophy of Software Design. John Ousterhout wrote it. He spent his career building operating systems and distributed systems — Tcl, RAMCloud, Raft consensus. Not exactly frontend territory.

But somewhere in that book, he describes a failure mode that I've seen in more Angular codebases than I can count.

He calls it a shallow module.


What a Module Actually Is

Every abstraction you create — a service, a directive, a component — has two dimensions.

Interface: the surface exposed to the caller. How much does someone need to know to use this thing?

Implementation: the work hidden inside. How much complexity does it actually absorb?

A deep module has a narrow interface and a powerful implementation. You interact with it through a small surface, and it does a lot for you on the other side of that surface.

A shallow module has an interface that costs roughly as much as what it gives back. You have to know a lot to use it, and it doesn't hide much in return.

Ousterhout's canonical example is Unix file I/O. Five calls — open, read, write, close, seek — hide decades of complexity: file systems, permissions, buffering, disk abstraction, OS scheduling. You don't know any of that exists. You just call read. That's a deep module.

Now think about the last service you wrote.


The Problem Is Worse in Frontend

Backend engineers are often forced into depth because they're hiding genuinely complex things — databases, networks, distributed state, file systems. The complexity exists before they write a single line of abstraction.

Frontend developers are different. Nobody forced you to create that service. Nobody required that directive. You chose to add a layer. Which means the question of whether it justifies its existence is sharper — and the failure mode of shallow abstraction is far more common.

You can write an entire Angular application full of files that follow every convention, satisfy every linter, pass every code review — and still have an app where every abstraction is lying to you. Where every layer pretends to be hiding something but is really just renaming what's below it.

Let me show you what that looks like. And what the alternative looks like.


Services: Absorb Complexity or Don't Exist

Here's the shallow version. You've seen it. You've probably written it.

@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(id: string) {
    return this.http.get(`/api/users/${id}`);
  }

  updateUser(id: string, payload: Partial<User>) {
    return this.http.put(`/api/users/${id}`, payload);
  }
}
Enter fullscreen mode Exit fullscreen mode

Looks clean. But now look at the component that uses it.

this.userService.getUser(id).subscribe({
  next: (user) => {
    this.user = user;
    this.loading = false;
  },
  error: (err) => {
    this.loading = false;
    this.error = 'Something went wrong';
    console.error(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

The component is managing loading state, error state, type casting, and retry logic. The service renamed http.get and nothing else. Every component that calls this service will write this same ceremony independently.

That's a shallow service. The interface costs you an import and a constructor injection. The implementation gives you almost nothing in return.

Now here's what a service looks like when it earns its existence.

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private cache = new Map<string, User>();

  getUser(id: string) {
    // rxResource wraps the HTTP call and gives you .value(), .isLoading(),
    // and .error() as signals — no manual state management needed
    return rxResource({
      loader: () => {
        if (this.cache.has(id)) {
          return of(this.cache.get(id)!);
        }

        return this.http.get<User>(`/api/users/${id}`).pipe(
          retry(2),
          tap(user => this.cache.set(id, user))
        );
      },
      defaultValue: null,
    });
  }

  updateUser(id: string, payload: Partial<User>) {
    return rxResource({
      loader: () =>
        this.http.put<User>(`/api/users/${id}`, payload).pipe(
          tap(updated => this.cache.set(id, updated))
        ),
      defaultValue: null,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The caller — a standalone component, no async pipe, no subscription:

@Component({
  selector: 'app-user-profile',
  template: `
    @if (userResource.isLoading()) { <p>Loading...</p> }
    @if (userResource.error()) { <p>{{ normalizeError(userResource.error()) }}</p> }
    @if (userResource.value(); as user) {
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    }
  `
})
export class UserProfileComponent {
  private userService = inject(UserService);
  private userId = inject(ActivatedRoute).snapshot.params['id'];

  userResource = this.userService.getUser(this.userId);

  normalizeError(err: unknown): string {
    if (err instanceof HttpErrorResponse) {
      if (err.status === 404) return 'User not found.';
      if (err.status === 403) return 'You don\'t have permission to do this.';
      if (err.status >= 500) return 'Server error. Please try again later.';
    }
    return 'An unexpected error occurred.';
  }
}
Enter fullscreen mode Exit fullscreen mode

The component doesn't subscribe. Doesn't manage loading state. Doesn't catch errors manually. rxResource gives it .isLoading(), .value(), and .error() as signals — and Angular's new control flow (@if) reacts to them directly.

Notice that normalizeError lives in the component here — and that's intentional. The service absorbs infrastructure complexity: retries, caching, HTTP mechanics. Error presentation — what a 404 means to the user — is a UI concern. Each layer owns what belongs to it.

When the backend team changes their error format, you fix it in one place. Every component heals automatically.

That's what depth looks like. The interface is a single method call. The implementation absorbs retries, caching, Observable-to-Signal bridging, and loading state — none of which the caller ever sees.


Directives: Hide Coordination, Not Just Classes

The shallow version:

@Directive({ selector: '[appHighlight]' })
export class HighlightDirective {
  constructor(private el: ElementRef) {}

  @HostListener('mouseenter')
  onMouseEnter() { this.el.nativeElement.classList.add('highlighted'); }

  @HostListener('mouseleave')
  onMouseLeave() { this.el.nativeElement.classList.remove('highlighted'); }
}
Enter fullscreen mode Exit fullscreen mode

A directive that toggles one class on hover. You could replace this with a CSS :hover rule. It exists as a file, needs to be declared, imported, and understood — for two lines of functionality. Shallow.

A directive becomes deep when it hides something the caller genuinely cannot do cheaply: event coordination, DOM lifecycle, cleanup contracts, timing logic. Here's a real example — click-outside detection.

@Directive({ selector: '[appClickOutside]', standalone: true })
export class ClickOutsideDirective {
  readonly clickOutside = output<void>();
  readonly enabled = input<boolean>(true);

  private el = inject(ElementRef);
  private renderer = inject(Renderer2);
  private destroyRef = inject(DestroyRef);

  constructor() {
    // setTimeout defers listener attachment past the click that opened this element
    const timer = setTimeout(() => {
      const unlisten = this.renderer.listen('document', 'click', (event: MouseEvent) => {
        if (!this.enabled()) return;
        const clickedInside = this.el.nativeElement.contains(event.target);
        if (!clickedInside) this.clickOutside.emit();
      });

      // DestroyRef replaces ngOnDestroy — cleanup is co-located with setup
      this.destroyRef.onDestroy(() => unlisten());
    });

    // Clean up the timer itself if directive is destroyed before it fires
    this.destroyRef.onDestroy(() => clearTimeout(timer));
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<div class="dropdown" appClickOutside (clickOutside)="closeDropdown()">
  ...
</div>
Enter fullscreen mode Exit fullscreen mode

The component writing this has no idea about:

  • The setTimeout trick that prevents the originating click from immediately closing the element it just opened
  • The fact that Renderer2.listen returns an unlisten function that must be called manually
  • The DestroyRef cleanup that prevents memory leaks and stale listeners — and that the timer itself needs clearing if the directive is destroyed before it fires
  • The contains() check that correctly handles clicks on child elements
  • The fact that enabled is a signal — this.enabled() — so it reads the latest value reactively inside the listener

Every component that needs click-outside behavior gets all of that for free. Without this directive, each one would reimplement it — and probably miss the setTimeout trick, skip the cleanup, or break on nested clicks.

Remove the directive and the caller gets substantially more complex. That's depth.


Smart/Dumb Components: The Split That Goes Wrong

The smart/dumb component pattern is one of the most useful ideas in frontend architecture. It's also one of the most commonly misapplied ones.

The failure mode: the smart component passes so many @Input() properties down that the dumb component becomes a post box — receiving state it didn't produce, relaying events it didn't originate, and hiding nothing.

// The dumb component that isn't doing anything dumb
@Component({ selector: 'app-user-profile-card', template: `...` })
export class UserProfileCardComponent {
  @Input() userId!: string;
  @Input() user: User | null = null;
  @Input() loading = false;
  @Input() error: string | null = null;
  @Input() isCurrentUser = false;
  @Input() canEdit = false;
  @Input() editMode = false;

  @Output() edit = new EventEmitter<void>();
  @Output() save = new EventEmitter<Partial<User>>();
  @Output() cancel = new EventEmitter<void>();
}
Enter fullscreen mode Exit fullscreen mode

Seven inputs. Three outputs. What does this component own? Nothing. It's a template with plumbing. Remove it and paste the template into the smart component — nothing meaningful changes.

The editMode flag is the clearest symptom. That's pure UI state. It has nothing to do with routing, services, or the outside world. The smart component has no business owning it. But because it's passed down, the smart component now manages three methods — onEdit, onCancel, onSave — that exist purely to shuttle state back up from a component that should have owned it locally.

Here's what the split looks like when the depth is in the right place.

Smart component:

@Component({
  selector: 'app-user-profile-page',
  standalone: true,
  template: `
    <app-user-profile-card
      [userResource]="userResource"
      [permissions]="permissions()"
      (save)="onSave($event)"
    />
  `
})
export class UserProfilePageComponent {
  private route = inject(ActivatedRoute);
  private userService = inject(UserService);
  private authService = inject(AuthService);

  private userId = this.route.snapshot.params['id'];

  // rxResource gives the card .isLoading(), .value(), .error() as signals
  userResource = this.userService.getUser(this.userId);

  // permissions is a signal — no async pipe, no subscription
  permissions = this.authService.permissionsFor(this.userId);

  onSave(payload: Partial<User>) {
    this.userService.updateUser(this.userId, payload);
  }
}
Enter fullscreen mode Exit fullscreen mode

Dumb component:

@Component({ selector: 'app-user-profile-card', standalone: true, template: `
  @if (userResource.isLoading()) { <p>Loading...</p> }
  @if (userResource.error()) { <p>Something went wrong.</p> }

  @if (userResource.value(); as user) {
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>

    @if (permissions()?.canEdit && !editMode()) {
      <button (click)="editMode.set(true)">Edit</button>
    }

    @if (editMode()) {
      <app-user-edit-form
        [user]="user"
        (save)="handleSave($event)"
        (cancel)="editMode.set(false)"
      />
    }
  }
`})
export class UserProfileCardComponent {
  readonly userResource = input.required<ResourceRef<User>>();
  readonly permissions = input<{ isCurrentUser: boolean; canEdit: boolean } | null>(null);

  readonly save = output<Partial<User>>();

  // editMode is local signal state — the smart component never touches it
  editMode = signal(false);

  handleSave(payload: Partial<User>) {
    this.save.emit(payload);
    this.editMode.set(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

Seven inputs became two. Three outputs became one. editMode moved to where it belongs — inside the component whose job is to manage that interaction.

The dumb component got simpler at the interface but richer in behavior. It now owns its local state, manages its own transitions, and cleans up after itself. Remove it now and the smart component would have to absorb all of that. Which means it was actually hiding something.


The Test That Unifies All Three

Every time you create an abstraction, ask one question:

If I delete this, does the caller get meaningfully more complex?

If yes — the abstraction was earning its depth. Keep it.

If no — it was a layer without a reason. The complexity didn't get hidden. It got deferred upward, to be rediscovered by every caller, independently, forever.

This isn't about how many files you have or how well you follow conventions. A codebase can be perfectly structured and still full of abstractions that are lying about how much work they're doing.

Ousterhout wrote about this in the context of operating systems. But the failure mode is identical in a five-file Angular feature module. The scale is different. The pattern is the same.


A Note If You've Been Reading This Series

This post is part of an ongoing series called Don't Fight the Framework, Embrace It — about what it actually means to understand Angular at the level where you stop guessing and start knowing.

That posts covered the rendering engine, Ivy's locality principle, LView/TView internals, and how change detection actually works under the hood.

This one is different. It's not about Angular internals. It's about a design principle old enough to predate Angular by decades — and why it applies to your codebase more precisely than most Angular-specific advice does.

The internals tell you how the framework works. This tells you whether what you're building on top of it is worth building at all.

Top comments (0)