Skip to content

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
src/subscription.process.tsTypeScript
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
src/route.tsTypeScript
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<T>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
src/domain.tsTypeScript
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
src/order.tsTypeScript
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<T> is unsaved and writable. Persistent<T> is stored and readonly. Lock<Persistent<T>> 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
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
src/route.tsTypeScript
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<T> unifies all ways to point at an entity: Reference<T> | Persistent<T> | Lock<Persistent<T>>. Services accept Ref<T> — pass a persistent entity directly, or convert a string at the boundary with User.ref`${userId}`.

8. Async context is the framework

JustScale uses AsyncLocalStorageto 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
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 hopedyour code behaved distributed-safely. It doesn't. Four mechanical rules, all type-checked, close the loop:

  • Every mutating repository method requires Locked<T>. save, update, and delete refuse a bare Persistent<T> at compile time. The only way to obtain a Locked<T> 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<T> cannot cross process boundaries. The serializer throws if a Locked<T> 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.

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
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 userin 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-metadatareads 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 instanceofchecks. They're the best balance of type safety and runtime utility in TypeScript.