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:

TypeScript
async handler({ billing, notifications }, [userId]) {
  while (true) {
    const r = race();
    switch (true) {
      case delay.months(r, 1):
        await billing.charge(userId);
        await notifications.send(userId, 'Payment processed');
        continue;

      case signal(r, billing.cancellation):
        await notifications.send(userId, 'Subscription cancelled');
        return { status: 'cancelled' };
    }
  }
}

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.

TypeScript
// Every other framework
const user = await userRepo.findById('abc123');
const order = await orderRepo.findById(user.orderId);
user.id;  // string — could be confused with any other string

// JustScale
const user = await users.findOne(User.fields.email.eq('alice@example.com'));
const order = await orders.get(user);  // persistent instance IS a reference
// user has NO .id property — it's pure domain data

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.

TypeScript
// Domain model — pure, no storage details
class User extends defineModel({
  fields: { email: field.string(), name: field.string() },
}) {}

// PostgreSQL adapter — owns storage details
const PgUser = createPgModel(User, { table: 'users' });
// PG decides: UUID for id, timestamps, version column

// In-memory adapter — for tests
const MemUser = createInMemoryModel(User);
// In-memory decides: random string for id, Date.now() for timestamps

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.

TypeScript
class Order extends defineModel({
  fields: { amount: field.decimal(10, 2), status: field.enum('Status', ['pending', 'paid']) },
  inject: { payments: PaymentService },
}) {
  // I need a locked persistent order — safe to mutate
  async markPaid(this: Lock<Persistent<Order>>) {
    this.status = 'paid';
  }

  // Works on either new or locked — both are writable
  async applyDiscount(this: Transient<Order> | Lock<Persistent<Order>>, pct: number) {
    this.amount *= (1 - pct / 100);
  }

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

  // Works on anything
  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.

TypeScript
class Campaign extends defineModel({
  fields: {
    creator: field.ref(Creator),      // Reference<Creator>, not a string ID
    title: field.string().max(255),
    goalAmount: field.decimal(12, 2),
  },
}) {}

const campaign = await campaigns.findOne(Campaign.fields.title.eq('My Project'));
const creator = await campaign.creator;   // Reference is PromiseLike — just await it

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/:campaignId/lifecycle',
  inject: { campaigns: CampaignService, pledges: PledgeService, payments: PaymentService },

  async handler({ campaigns, pledges, payments }, [campaignId]) {
    const campaignRef = Campaign.ref`${campaignId}`;
    using campaign = await campaigns.get(campaignRef);

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

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.

Common Questions

Why no decorators?

Decorators require runtime reflection and emit metadata. They also have limited TypeScript support for type inference. JustScale achieves better type safety with plain functions — what you see is what runs.

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.