<!-- Markdown mirror of https://justscale.sh/docs/overview/philosophy -->

# Philosophy

The nine principles behind JustScale's design

JustScale is built on a simple observation: most backend code is infrastructure, not business logic. These nine principles guide every design decision in the framework.

## Domain Purity

### 1. Domain code describes WHAT, never HOW

Your code should read like a description of what happens, not how it happens. A subscription that charges monthly, cancels on request, and times out after inactivity should look exactly like that:

Files

srcdomain.tsservices.tssubscription.process.ts

srcdomain.tsservices.tssubscription.process.ts

src/subscription.process.tsTypeScript

```typescript
import { createProcess, signal, race, delay } from '@justscale/core/process';
import { User } from './domain';
import { BillingService, BillingSignals, NotificationService } from './services';

// A subscription that charges monthly and cancels on request.
// Runs for months. Survives server restarts. Works across multiple instances.
// But reads like a plain loop with a timer.
export const subscription = createProcess({
  path: '/subscription/:user',
  types: { User },                         // :user is Reference<User> in the handler
  inject: {
    billing:       BillingService,
    notifications: NotificationService,
    signals:       BillingSignals,
  },

  async handler({ billing, notifications, signals }, { user }) {
    while (true) {
      const r = race();
      switch (true) {
        case delay.days(r, 30):
          await billing.charge(user);
          await notifications.send(user, 'Payment processed');
          continue;

        case signal(r, signals.cancellation):
          await notifications.send(user, 'Subscription cancelled');
          return { status: 'cancelled' as const };
      }
    }
  },
});
```

This process runs for months. It survives server restarts. It works across multiple instances. But the code reads like a simple loop with a monthly timer.

### 2. IDs do not exist in domain code

An ID is an infrastructure detail — it's how your storage layer tracks entities. It's not a domain concept. Raw strings enter the app at the boundary (a route param, an event, a signal payload) and are immediately branded into typed references. From there, domain code just awaits them.

Files

srcdomain.tsroute.ts

srcdomain.tsroute.ts

src/route.tsTypeScript

```typescript
import { Get } from '@justscale/http';
import { User, Order } from './domain';

// The boundary — and even here no raw string ID appears. `.types({ User })`
// matches the `:user` path param against the model key and brands it into a
// `Reference<User>` before the handler runs. Awaiting a Reference hydrates
// it to `Persistent<User>`.
export const getMyOrder = Get('/users/:user/order')
  .types({ User, Order })
  .handle(async ({ params, res }) => {
    const user = await params.user;    // Reference<User> → Persistent<User> | null
    if (!user) { res.status(404).end(); return; }

    // user.currentOrder is already a typed ref (field.ref(Order)). Just await.
    // No lookup call, no .orderId leaking through, no cast.
    const order = user.currentOrder && await user.currentOrder;

    res.json({ order });
  });
```

`Persistent`means "this entity is stored." That's it. No `.id`, no `.createdAt`, no system fields. Those are adapter concerns, stored internally via non-enumerable symbols.

### 3. Adapters own their concerns

The domain defines models. Adapters implement storage. The adapter decides what IDs look like, how timestamps work, and how queries are optimized.

Files

srcdomain.tsmemory.tspg.ts

srcdomain.tsmemory.tspg.ts

src/domain.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

// Pure domain — no IDs, no timestamps, no storage hints. Just fields.
export class User extends defineModel({
  fields: {
    email: field.string().max(255).unique(),
    name:  field.string().max(100),
  },
}) {}
```

Domain code never changes when you swap adapters.

## Type System as Contract

### 4. You tell us what you need, we give you data in the right form

Methods declare what form of data they need via `this` parameter types. The type signature IS the contract — if it compiles, the caller provided the right data in the right state.

Files

srcorder.tspayments.ts

srcorder.tspayments.ts

src/order.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';
import type { Lock, Persistent } from '@justscale/core/models';
import { PaymentService } from './payments';

export class Order extends defineModel({
  fields: {
    amount: field.int(),
    status: field.enum('OrderStatus', ['pending', 'paid', 'shipped']).default('pending'),
  },
  inject: { payments: PaymentService },
}) {
  // I need a locked persistent order — safe to mutate, nobody else can write concurrently.
  async markPaid(this: Lock<Persistent<Order>>) {
    this.status = 'paid';
  }

  // I need a stored order — read-only access, no lock needed.
  async loadItems(this: Persistent<Order>) {
    return this.payments.getItemsFor(this);
  }

  // Works on anything — doesn't touch fields, so no `this` type needed.
  validate() {
    return this.amount > 0;
  }
}
```

💡Tip

**Type states:** `Transient` is unsaved and writable. `Persistent` is stored and readonly. `Lock>` is stored, locked, and writable. The compiler enforces these contracts.

### 5. Models are services

A model instance is not just data. Its prototype is a resolved service — a singleton with injected dependencies that every instance shares.

text

```text
instance (own props: field data)
  → modelService (injected deps, non-enumerable)
    → ModelClass.prototype (methods from class body)
      → BaseModel.prototype
```

When you call `this.payments` on a model instance, it walks the prototype chain to the resolved service. All instances share the same service. Fields are own properties. Methods come from the class. Dependencies come from the prototype.

### 6. If it compiles, it works

The type system is not documentation — it's a contract enforcement mechanism. Every relationship between components is verified at compile time:

- Missing dependencies → type error
- Wrong data form (need locked, got readonly) → type error
- Wrong reference type → type error
- Impossible cross-adapter query → detected at boot

The goal: **zero runtime surprises that could have been caught statically.**

## Invisible Infrastructure

### 7. References replace relationships

You don't store foreign keys. You store references — type-safe, memoized, and scoped to the current framework instance.

Files

srcmodels.tsroute.ts

srcmodels.tsroute.ts

src/route.tsTypeScript

```typescript
import { Get } from '@justscale/http';
import { Campaign } from './models';

// Path param becomes Reference<Campaign> via .types(). The handler never sees
// a string ID. Related entities are typed refs — awaiting them hydrates.
export const getCampaign = Get('/campaigns/:campaign')
  .types({ Campaign })
  .handle(async ({ params, res }) => {
    const campaign = await params.campaign;
    if (!campaign) { res.status(404).end(); return; }

    // `field.ref(Creator)` is Reference<Creator> — PromiseLike, just await.
    const creator = await campaign.creator;

    // `field.refs(Tag)` is References<Tag> — also PromiseLike; resolves all at
    // once (batched — Postgres uses a dataloader, in-memory resolves inline).
    const tags = await campaign.tags;

    res.json({
      title: campaign.title,
      by:    creator?.name,
      tags:  tags.map((t) => t.label),
    });
  });
```

`Ref` unifies all ways to point at an entity: `Reference | Persistent | Lock>`. Services accept `Ref` — pass a persistent entity directly, or convert a string at the boundary with `User.ref`${userId}``.

### 8. Async context is the framework

JustScale uses `AsyncLocalStorage`to track which instance you're in. This means:

- No context objects to pass around — Model.ref(entity) works anywhere
- Multiple framework instances in one process are isolated (multi-tenancy)
- Works in callbacks, setTimeout, external library code — the async context is always there

### 9. Durable processes as plain code

Long-running workflows are written as plain TypeScript that the compiler transforms into resumable state machines with opcode-based persistence.

campaign-lifecycle.process.tsTypeScript

```typescript
export const campaignLifecycle = createProcess({
  path: '/campaign/:campaign/lifecycle',
  types: { Campaign },             // :campaign → Ref<Campaign> in the handler
  inject: { campaigns: CampaignService, pledges: PledgeService, payments: PaymentService },

  async handler({ campaigns, pledges, payments }, { campaign }) {
    const found = await campaign;  // resolve the Ref to a Persistent
    if (!found) return { status: 'failed', reason: 'not-found' };

    const r = race();
    switch (true) {
      case signal(r, pledges.fullyFunded): {
        await campaigns.setStatus(campaign, 'settling');
        for (const pledgeId of await pledges.findIds(campaign)) {
          await payments.charge(Pledge.ref(pledgeId));
        }
        return { status: 'completed', campaign: found };
      }
      case delay.days(r, found.durationDays): {
        await campaigns.setStatus(campaign, 'failed');
        return { status: 'failed', campaign: found };
      }
    }
  },
});
```

This compiles to an opcode-based state machine. It survives restarts, works across multiple instances, and scales to millions of concurrent processes.

## Write for One, Scale to Many

**The best code doesn't know it's distributed.**

When you write a goroutine in Go, you write straightforward, synchronous-looking code. You don't think about thread pools or CPU scheduling. The Go runtime handles those complexities invisibly.

JustScale brings this same philosophy to distributed systems. You write code as if your application runs on a single instance — acquire a lock, update an entity, store some state — and the framework transparently handles clustering, distributed coordination, and horizontal scaling. Need to ensure only one instance processes a task? Just use `using lock = await lockService.acquire(entity)`. The framework decides whether that's a local mutex or a distributed lock based on your deployment. Your code doesn't change. It *just scales*.

This philosophy appears throughout great programming tools:

- Go's goroutines — synchronous-looking code, the runtime makes it concurrent
- React's declarative UI — describe what the UI should look like, React figures out the mutations
- SQL — express what data you want, the engine chooses execution plans

JustScale follows this tradition: **you write simple, instance-local code, and the framework handles distributed concerns automatically.**

### What enforces this

The analogy would be empty if the framework merely *hoped*your code behaved distributed-safely. It doesn't. Four mechanical rules, all type-checked, close the loop:

- Every mutating repository method requires Locked. save, update, and delete refuse a bare Persistent at compile time. The only way to obtain a Locked is repo.lock(ref).
- repo.lock() is atomic with the read. On Postgres it is a single SELECT ... FOR UPDATE; the entity contents come from a row read under the lock, not from whatever the caller passed in. Stale-write bugs are structurally impossible.
- Locked cannot cross process boundaries. The serializer throws if a Locked reaches a signal payload or any other wire format. Lock guarantees are a local async-context fact; sending one across the network would be a lie, so the framework refuses to.
- Signals carry routable identity, not free-form payloads. defineSignals forces every path parameter through .types({Model}); the path is the topic on the pg NOTIFY bus, and the typed params are the routing key. A signal that cannot be routed cannot be defined.

The result: the same domain code that runs on your laptop against an in-memory lock provider runs correctly on a 20-node deployment against Postgres advisory locks, without a single line of change. Not because the framework is clever, but because the type system already refused everything that *would* have been wrong. For the full mechanical walkthrough — including the multi-process end-to-end test that proves this — see [**Why It Scales**](https://justscale.sh/docs/advanced/why-it-scales).

## Common Questions

### Why no decorators?

Two reasons. One is mechanical — decorators lean on runtime reflection (`reflect-metadata`), emit hidden side effects, and TypeScript can't infer much through them. Plain functions are simpler: what you see is what runs.

The bigger reason is that decorators are *isolated*. Each decorator runs in its own context and can't see what another decorator did. A chained builder does the opposite — each step returns a new builder whose type *remembers* what the previous step added, so later steps are type-safe in terms of earlier ones:

TypeScript

```typescript
Get('/users/:user/tickets')
  .types({ User })                  // params.user: string → Reference<User>
  .use(auth)                        // adds { user: Persistent<User> } to ctx
  .use(loadTickets)                 // sees ctx.user + params.user — both typed
  .guard(canListTickets)            // sees everything the chain has accumulated
  .returns(200, TicketListSchema)
  .handle(({ params, user, tickets, res }) => {
    // params.user: Reference<User>   — from .types()
    // user: Persistent<User>         — from .use(auth)
    // tickets: Persistent<Ticket>[]  — from .use(loadTickets)
    res.json(tickets)
  })
```

With decorators, `@LoadTickets` has no idea whether`@Auth` ran, what it added, or what shape the route params have. You reach for runtime `any` and hope. With a chain, if you put `.use(loadTickets)` before`.use(auth)`, TypeScript catches it — the required`user`in its input isn't in scope yet.

There's a third, subtler problem. Decorators fuse the type graph with the module graph. `@Inject() private repo: UserRepository` only works because TypeScript emits the *value* of`UserRepository` alongside the decorator and`reflect-metadata`reads it at class-creation time. A property's *type annotation* is now a live runtime import. That coupling breaks in three ways:

- Circular imports move from compile-time to runtime. A cycle at the type level is fine; a cycle where each decorator fires and needs the sibling class as a value produces a silent undefined on whichever side initialises second. The bug depends on import order.
- Type-only imports stop being an escape hatch. import type erases at compile time — useless if the decorator needs the class as a value.
- Same file, different identity. Monorepos resolve the same module through path aliases, workspace protocols, symlinks, nested node_modules. With decorators, two canonical paths become two tokens in the DI container — silently splitting a singleton in half.

JustScale passes the token as a *value*: `inject: { repo: UserRepository }`. That's a plain JavaScript object — no reflection, no metadata, no implicit link between a type annotation and the module graph. Node's module cache deduplicates imports for us, so every path that resolves to the same file yields the same class reference and the same token. Rename a path alias, split a file, move to project references — nothing reaches across and breaks.

### Why abstract classes for repositories?

Abstract classes (vs interfaces) provide runtime tokens for dependency injection and support `instanceof`checks. They're the best balance of type safety and runtime utility in TypeScript.

## Next Steps

- Services
- Models Overview
- Type States
