Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

The Fine-Grained Projections

This is the article where the AOP objection bites and the cross-cutting case has to be defended. The previous article (types) got away without confronting cross-cutting because types are by nature single-site declarations; classes and methods and functions are not. An intention can project onto many methods scattered across many classes, and the framing has to have something to say about that.

The article sketches the API surface and then argues about whether it is anything more than aspect-oriented programming wearing different clothes.

The class case

A class is denser than a type. It carries state, behaviour, invariants, lifecycle. A class extension declares that the class as a whole is a projection of an intention:

@ClassExtension(BoundedAuthenticatedLifetime)
export class AuthSession {
  constructor(
    public readonly userId: UserId,
    public readonly issuedAt: Date,
    public readonly expiresAt: Date,
  ) {
    if (expiresAt <= issuedAt) {
      throw new Error('AuthSession expiresAt must be after issuedAt');
    }
  }

  isExpired(now: Date = new Date()): boolean {
    return now >= this.expiresAt;
  }
}

What does the decoration claim? Roughly: this class exists because the BoundedAuthenticatedLifetime intention required it, and the class's invariants and methods are evidence of that intention. The analyzer's job would be to check that the evidence is present — for instance, that some constructor invariant relates issuedAt and expiresAt, that some method tests for expiry, that the class does not silently allow the lifetime to be widened (no setExpiresAt setter, no mutable lifecycle).

This is concrete enough to be testable. It is also concrete enough to ask: is the class extension itself the invariant, or is the class extension a container for a finer-grained set of method extensions and field declarations that together carry the intention?

I lean toward container. The class is the site of the projection; the actual evidence lives at the method and field level. The class-level decoration is a promise that the finer-grained evidence is present. This will matter for the AOP discussion below.

The method case

Methods are where the cross-cutting question becomes unavoidable. Consider:

class UserRepository {
  @MethodExtension(AuditableStateChanges)
  async save(user: User): Promise<void> {
    await this.audit(user, 'save');
    await this.db.users.put(user);
  }

  @MethodExtension(AuditableStateChanges)
  async delete(userId: UserId): Promise<void> {
    await this.audit({ userId }, 'delete');
    await this.db.users.delete(userId);
  }

  private async audit(payload: unknown, action: string): Promise<void> {
    /* ... */
  }
}

class OrderRepository {
  @MethodExtension(AuditableStateChanges)
  async save(order: Order): Promise<void> {
    await this.audit(order, 'save');
    await this.db.orders.put(order);
  }

  @MethodExtension(AuditableStateChanges)
  async cancel(orderId: OrderId): Promise<void> {
    await this.audit({ orderId }, 'cancel');
    await this.db.orders.delete(orderId);
  }

  private async audit(payload: unknown, action: string): Promise<void> {
    /* ... */
  }
}

The same intention — AuditableStateChanges — projects onto four methods across two classes. The methods are not located in a single module; they are scattered across the codebase wherever state-changing operations happen on auditable aggregates. The intention is cross-cutting in the AOP sense: it cuts across the class hierarchy.

The analyzer's job here is more interesting than in the type or class case. It has to recognise that:

  1. Every method decorated with @MethodExtension(AuditableStateChanges) contains some pattern — a call to audit, a call to a logger with structured audit fields, a wrapping decorator — that constitutes the audit projection.
  2. Every state-changing method on an auditable class that is not decorated is a candidate orphan. The analyzer cannot definitively say "you forgot the audit" — methods may legitimately not need auditing — but it can flag the asymmetry: this method changes state, its siblings on the same class are audited, this one is not.
  3. The AuditableStateChanges intention itself can be queried: "give me every method, across every class, that projects from you." The analyzer materialises that query into a manifest that lives in src/generated/ and is regenerated on every build.

The third capability is the one I want to highlight. It is the bidirectional registry surfacing again — an intention can enumerate its extensions across the entire codebase. AOP cannot quite do this. In an AOP framework, the aspect knows where its pointcuts apply (via matcher expressions), but the applied sites are not a structured registry; they are the runtime result of pointcut evaluation. The intention framing inverts this: the sites are declared, and the intention is queried through them.

The function case

Free functions are the same picture as methods but without the class container:

@FunctionExtension(AuditableStateChanges)
export async function updateUserEmail(userId: UserId, newEmail: Email): Promise<void> {
  await audit({ userId, newEmail }, 'updateUserEmail');
  await db.users.update(userId, { email: newEmail });
}

The function-level decoration carries the same semantics as the method-level decoration: this function exists as a projection of the intention. The registry includes free functions, methods, and class extensions in a uniform manifest.

The only interesting wrinkle is that free functions do not have a class containing them, so the "asymmetry detection" rule (method orphan candidate detection) does not have a natural unit to compare against. The analyzer's heuristic has to be different — perhaps based on module siblings, or on call-graph proximity, or simply on a per-intention rule that the developer configures.

The objection: you have reinvented aspect-oriented programming

This is the objection I want to engage at length. It is the one most likely to land in a thoughtful reader's head, and it is the one I am least confident I can fully answer.

The objection runs: aspect-oriented programming has been around since AspectJ in the early 2000s. It models cross-cutting concerns explicitly. It has pointcuts (where the aspect applies), advice (what the aspect does), and weaving (how the aspect is materialised at compile or runtime). The Intention/MethodExtension apparatus you are sketching is just AspectJ in TypeScript decorators with the vocabulary changed. The historical lessons of AspectJ — including its eventual unpopularity outside a few narrow contexts — should be heeded, not reinvented.

I want to engage this in four steps.

Step one: concede the genuine overlap. The cross-cutting case as I have described it is exactly what AOP was designed for. The audit-on-state-change example is the textbook AOP example. If you replace @MethodExtension(AuditableStateChanges) with @Audit in a TypeScript-decorator-based AOP library (and several of these exist, including InversifyJS middleware and various NestJS interceptor patterns), the surface API is nearly identical. The pointcut becomes "every method decorated with @Audit," the advice becomes the audit logger invocation, the weaving becomes the decorator's runtime wrapping of the method. AOP solves the cross-cutting problem mechanically as well as the intention framing solves it mechanically.

Step two: identify the actual difference. The difference is what the decoration is a pointer to. In AOP, a method is decorated with an aspect — a piece of behaviour to be woven in. The aspect is a thing that does something to the method. The decoration is mechanical. In the intention framing, a method is decorated with an intention — a piece of upstream rationale that the method projects from. The intention is not a thing that does something to the method; it is a thing the method is evidence of. The decoration is referential, not active.

This difference has API consequences. An AOP aspect, when applied, wraps the method or injects behaviour. The aspect is invasive: the method's runtime behaviour is changed by the decoration. An intention extension does not wrap the method. The decoration is metadata only. The method's runtime behaviour is whatever the developer wrote; the intention reference does not interpose. The analyzer can check that the method's behaviour is consistent with the intention's projection (e.g., "this method should audit; does it?"), but the analyzer does not force the auditing — the developer writes the audit call themselves and the analyzer verifies its presence.

The consequence is that the intention framing is static: it produces a registry, surfaces a manifest, runs static checks. AOP is dynamic (in the sense of changing runtime behaviour, even when woven at compile time): it intercepts. These two are genuinely different mechanisms even when they decorate the same syntactic locations.

Step three: acknowledge that the difference is small from one angle. From the perspective of a developer who only wants the audit to be applied, the static-versus-active distinction is a developer experience trade-off, not an architectural one. AOP is less work in that case because the aspect does the wrapping automatically. The intention framing requires the developer to write the audit call by hand and then verifies it; AOP just wraps. For pure cross-cutting implementation, AOP wins on ergonomics.

The intention framing wins when the goal is not cross-cutting implementation but cross-cutting traceability. If the question is "which methods carry the audit intention, why, and where did the intention come from," the registry-based intention framing answers it. AOP's answer is "the aspect matches these pointcuts" — true and accurate but at the wrong level. The intention framing's answer is "these methods are projections of this named upstream object, which refines this other upstream object, which corresponds to this requirement, which traces to this regulatory clause." The chain is queryable; the AOP analog is not.

Step four: accept the asymmetry. The honest position is that for cross-cutting implementation, AOP is older and better. For cross-cutting traceability, the intention framing has something AOP does not. Codebases that mostly want implementation should keep using AOP. Codebases that mostly want traceability (regulated codebases, audited codebases, security-sensitive codebases) might benefit from the intention framing. The two are not in direct competition; they answer different questions about the same code.

If this acknowledgement is right, the intention framing is not a replacement for AOP. It is a complement. The packaging proposal in Part 9 should reflect this — perhaps by explicit interoperability with AOP-style decorators (a method can be both @Audit from an AOP library and @MethodExtension(AuditableStateChanges) from the intentions kernel, and the kernel verifies the audit by checking the AOP decoration is present, without owning the wrapping itself).

The mixin question, briefly

There is a smaller objection adjacent to the AOP one: mixins. A mixin is a class fragment that can be composed into multiple classes, providing shared behaviour. Some of the cross-cutting cases I am describing could be solved by extracting the behaviour into a mixin and composing it into the relevant classes. The mixin would carry the intention by being named after it (AuditableMixin), and the intention would be tracked by mixin usage rather than by decoration.

This is partially viable. It works well when the cross-cutting behaviour is structurally the same across all sites (the audit call signature is the same, the audit fields are the same). It works poorly when the cross-cutting behaviour is intentionally the same but structurally different (the audit call differs by aggregate type, the fields differ by operation, the timing differs by context). For the audit case, mixins force a uniformity that does not match how audit is actually implemented in real codebases. The intention framing accommodates structural variation by checking presence-of-pattern rather than exact-replication.

For the cases mixins handle well, mixins are simpler. The intention framing should not compete with mixins; it should be aware that some cross-cutting cases are best handled by composition and that the registry should record the mixin-usage as the projection's mechanism.

What stays open

I do not know whether the registry-and-manifest capability is asked for often enough to justify the kernel. I do not know whether the verification step (analyzer checks that the method's behaviour matches the intention's projection) can be made strong enough to be useful without becoming over-specified. I do not know whether the asymmetry detection (orphan candidate flagging) is signal or noise in real codebases — it may be the latter.

The biggest unresolved question after this article is whether the intention framing has any reason to exist for codebases that already use AOP for cross-cutting and DDD for modelling. The framing might be a strict subset of those two for those codebases, in which case the audience for the proposed packages is "codebases that use neither AOP nor DDD but still want traceability" — a smaller audience than I would like.

Part 7 takes the framing to the coarse-grained projections — modules and entire applications — where the AOP objection no longer applies but a different objection ("at this granularity, extension is just implementation") takes its place.

⬇ Download