Internal State: Not Evil, Just Misplaced

State is everywhere. And that’s not a problem — unless you pretend it isn’t. In real-world systems, especially those that span multiple scopes like backend applications, state is inevitable. The key isn’t to eliminate it — it’s to manage it deliberately. While building a new dependency injection library for TypeScript called TypeWire, I applied the same architectural principles I’ve long followed — particularly around clarity in boundaries and ownership of state. This article shares lessons learned from that experience, using the internal evolution of TypeWire to illustrate how state can be structured, isolated, and owned with confidence — without becoming ambient or unpredictable. Internal State in Practice I’ve been crafting a project called TypeWire, a DI library for TypeScript, and along the way I’ve learned a lot about designing internal state with strong boundaries. TypeWire started with a simple container class that handled everything — bindings, singleton caches, and resolution state — all internally. class TypeWireContainer implements ResolutionContext, BindingContext { private readonly bindings = new Map(); private readonly singletons = new Map(); private readonly resolutions = new Map(); async get(typeSymbol: TypeSymbol): Promise { // Resolution logic lived here } } This worked until I began hitting concurrency issues during parallel resolutions, particularly false positives in the circular dependency monitor. This pushed me toward isolating resolution per request. To fix that, I created a ScopedResolutionContext — an ephemeral context that owns each resolution lifecycle: class ScopedResolutionContext implements ResolutionContext { constructor( private readonly bindings: Map, private readonly singletons: Map, private readonly resolutionMonitor: ResolutionMonitor, ) {} } This made things easier to reason about and brought clear ownership to resolution-time behavior. But resolution-specific mutable state still lived in this class. So I extracted that too — into ResolutionState, whose sole job is to track in-flight data during resolution. class ResolutionState { readonly resolutions = new Map(); } This gave me three clear layers: TypeWireContainer: orchestrates the lifecycle ScopedResolutionContext: owns per-resolution behavior ResolutionState: holds mutable state, tightly scoped and invisible elsewhere These decisions were made iteratively — each step clarified boundaries and reduced ambiguity State can be refined gradually Ownership boundaries can evolve toward clarity Internal structure matters as much as public API A well-abstracted public API makes this evolution possible — since behavior remains stable while internals improve Scoped State and Behavioral Boundaries Short-lived or request-bound state can be tricky to manage — especially when it spreads into shared objects or global modules. In my own work, I try to construct state on demand and ensure it’s owned by a tightly scoped boundary. In the same system, transient resolution data — like in-flight dependency graphs — is encapsulated inside a ResolutionState class. This class lives only during resolution, and is owned by a ScopedResolutionContext wrapper: class ScopedResolutionContext { constructor(private readonly resolutionState: ResolutionState, ...) {} } This pattern reflects a broader principle: Internal state isn't just hidden — it's delegated further to the layer that truly owns its lifecycle Mutability is minimized and localized Behavior is centralized around one interface When I work with contextual and temporary state, I try to give it a dedicated layer — and let behavior encapsulate its usage. Long-Lived vs Contextual State Some state lives for the lifecycle of the application (e.g., configuration caches, connection pools). Others live per request, per resolution, or per session. Issues can emerge when long-lived objects inadvertently hold onto short-lived context. In our example, singleton values are stored only once and safely. Meanwhile, contextual logic — like what’s needed during one resolution flow — is dynamically built per call and discarded afterward. The structure makes ownership obvious: Global state lives in a global container Resolution-specific state lives inside ResolutionState Nothing is ambient, and nothing crosses boundaries without intent Preventing Scope Bleed Architecture fails when context-sensitive state (like user identity or feature flags) leaks into shared objects. Even with a container, this can happen: A singleton accesses a value that changes every request Transient logic is accidentally shared What helps in these situations isn't a tool — it’s careful structure: Pass context explicitly Use factory functions for delayed evaluation Avoid injecting context into globally-shared constructs The container pattern can help, but only if the boundaries

Apr 15, 2025 - 19:59
 0
Internal State: Not Evil, Just Misplaced

State is everywhere. And that’s not a problem — unless you pretend it isn’t.

In real-world systems, especially those that span multiple scopes like backend applications, state is inevitable. The key isn’t to eliminate it — it’s to manage it deliberately.

While building a new dependency injection library for TypeScript called TypeWire, I applied the same architectural principles I’ve long followed — particularly around clarity in boundaries and ownership of state.

This article shares lessons learned from that experience, using the internal evolution of TypeWire to illustrate how state can be structured, isolated, and owned with confidence — without becoming ambient or unpredictable.

Internal State in Practice

I’ve been crafting a project called TypeWire, a DI library for TypeScript, and along the way I’ve learned a lot about designing internal state with strong boundaries.

TypeWire started with a simple container class that handled everything — bindings, singleton caches, and resolution state — all internally.

class TypeWireContainer implements ResolutionContext, BindingContext {
  private readonly bindings = new Map<symbol, TypeWire<unknown>>();
  private readonly singletons = new Map<symbol, unknown>();
  private readonly resolutions = new Map<symbol, Promise<unknown>>();

  async get<T>(typeSymbol: TypeSymbol<T>): Promise<T> {
    // Resolution logic lived here
  }
}

This worked until I began hitting concurrency issues during parallel resolutions, particularly false positives in the circular dependency monitor. This pushed me toward isolating resolution per request.

To fix that, I created a ScopedResolutionContext — an ephemeral context that owns each resolution lifecycle:

class ScopedResolutionContext implements ResolutionContext {
  constructor(
    private readonly bindings: Map<symbol, TypeWire<unknown>>,
    private readonly singletons: Map<symbol, unknown>,
    private readonly resolutionMonitor: ResolutionMonitor,
  ) {}
}

This made things easier to reason about and brought clear ownership to resolution-time behavior. But resolution-specific mutable state still lived in this class.

So I extracted that too — into ResolutionState, whose sole job is to track in-flight data during resolution.

class ResolutionState {
  readonly resolutions = new Map<symbol, Promise<unknown>>();
}

This gave me three clear layers:

  • TypeWireContainer: orchestrates the lifecycle
  • ScopedResolutionContext: owns per-resolution behavior
  • ResolutionState: holds mutable state, tightly scoped and invisible elsewhere

  • These decisions were made iteratively — each step clarified boundaries and reduced ambiguity

  • State can be refined gradually

  • Ownership boundaries can evolve toward clarity

  • Internal structure matters as much as public API

  • A well-abstracted public API makes this evolution possible — since behavior remains stable while internals improve

Scoped State and Behavioral Boundaries

Short-lived or request-bound state can be tricky to manage — especially when it spreads into shared objects or global modules.

In my own work, I try to construct state on demand and ensure it’s owned by a tightly scoped boundary.

In the same system, transient resolution data — like in-flight dependency graphs — is encapsulated inside a ResolutionState class. This class lives only during resolution, and is owned by a ScopedResolutionContext wrapper:

class ScopedResolutionContext {
  constructor(private readonly resolutionState: ResolutionState, ...) {}
}

This pattern reflects a broader principle:

  • Internal state isn't just hidden — it's delegated further to the layer that truly owns its lifecycle
  • Mutability is minimized and localized
  • Behavior is centralized around one interface

When I work with contextual and temporary state, I try to give it a dedicated layer — and let behavior encapsulate its usage.

Long-Lived vs Contextual State

Some state lives for the lifecycle of the application (e.g., configuration caches, connection pools). Others live per request, per resolution, or per session.

Issues can emerge when long-lived objects inadvertently hold onto short-lived context.

In our example, singleton values are stored only once and safely. Meanwhile, contextual logic — like what’s needed during one resolution flow — is dynamically built per call and discarded afterward.

The structure makes ownership obvious:

  • Global state lives in a global container
  • Resolution-specific state lives inside ResolutionState
  • Nothing is ambient, and nothing crosses boundaries without intent

Preventing Scope Bleed

Architecture fails when context-sensitive state (like user identity or feature flags) leaks into shared objects.

Even with a container, this can happen:

  • A singleton accesses a value that changes every request
  • Transient logic is accidentally shared

What helps in these situations isn't a tool — it’s careful structure:

  • Pass context explicitly
  • Use factory functions for delayed evaluation
  • Avoid injecting context into globally-shared constructs

The container pattern can help, but only if the boundaries are respected.

Encapsulation and Ownership

State is not the enemy. Unowned state is.

What makes the TypeWire container safe is not the lack of state — it’s the clarity of ownership:

  • The container owns global state
  • The resolution context owns transient state
  • No one else touches either

In your own system:

  • Choose who owns the cache
  • Decide where request-scoped values live
  • Expose behavior — not state

If a Map or a flag is being read and written by multiple layers, I consider it no longer internal. That’s when I try to wrap it, abstract it, and assign clear ownership.

Reflection: Does This Cover It?

Let’s recap what good internal state management looks like — and whether our example matches up:

Principle Reflected in TypeWire?
Scoped state is owned ✅ ScopedResolutionContext + ResolutionState
Long-lived state is isolated ✅ Singletons live in the container only
Nothing is ambient or implicit ✅ No global context injection
Access is through behavior ✅ All state hidden behind get, bind
Mutable state is not shared ✅ Encapsulated in internal maps
Contextual resolution is explicit ✅ Built per-call, never reused

Even internal constructs like ResolutionState are a reminder that internal state can benefit from layered ownership — and I’ve found that layering it often helps clarify intent.

Conclusion

State isn’t evil. But pretending your system doesn’t have any — or letting it sprawl uncontrolled — is.

In TypeWire, we embraced internal state:

  • Singleton caches exist — but behind a behavioral interface
  • Transient resolution is scoped — not ambient
  • Contextual state is passed or constructed — never injected blindly

This wasn’t solved all at once. These boundaries — and the state ownership behind them — were clarified over time. Each change made the next one easier, precisely because earlier decisions respected separation of concerns.

When internal state is well-scoped and behaviorally owned, it enables iterative improvements without ripple effects.

The result is a system that uses state effectively, safely, and transparently.

And that’s the goal for any architecture: not to be stateless, but to be clear about where state lives, who owns it, and what it supports.