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:
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.
// 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 dataPersistent<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.
// 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 timestampsDomain 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.
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
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.
instance (own props: field data)
→ modelService (injected deps, non-enumerable)
→ ModelClass.prototype (methods from class body)
→ BaseModel.prototypeWhen 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.
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 itRef<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.
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.