Models as Services
Model instances with injected dependencies via prototype chain
In most frameworks, models are anemic data containers. Business logic lives in separate service classes that receive model data as arguments. This splits behavior from the data it operates on — the "anemic domain model" anti-pattern.
In JustScale, a model instance is not just data. Its prototype is a resolved service. Methods, injected dependencies, and field data all live on the same object.
The Prototype Chain
When the framework boots, it resolves all injected dependencies and creates a "model service" — a singleton that becomes the prototype of every instance of that model:
instance (own props: field data — email, name, balance)
→ modelService (injected deps — payments, notifications)
→ ModelClass.prototype (methods — transfer, validate)
→ BaseModel.prototypeThis means when you access this.payments on a model instance, JavaScript walks the prototype chain to the resolved service. All instances share the same dependencies. Fields are own properties. Methods come from the class.
Rich Domain Models
With behavior colocated with data, your models become expressive domain objects rather than property bags:
class Campaign extends defineModel({
fields: {
creator: field.ref(Creator),
title: field.string().max(255),
goalAmount: field.decimal(12, 2),
currentAmount: field.decimal(12, 2).default('0.00'),
status: field.enum('CampaignStatus', [
'draft', 'active', 'funded', 'failed', 'completed',
]),
},
}) {
get progress() {
return Number(this.currentAmount) / Number(this.goalAmount);
}
get isFundable() {
return this.status === 'active' && this.progress < 1;
}
}The model owns its domain logic. campaign.progress and campaign.isFundable live where they belong — on the entity itself, not in a separate CampaignService that takes a campaign object as an argument.
Dependency Injection on Models
For advanced cases, models can inject services. This lets model methods access framework capabilities without the caller needing to provide them:
class Order extends defineModel({
fields: {
amount: field.decimal(10, 2),
status: field.enum('Status', ['pending', 'paid']),
},
inject: { payments: PaymentService },
}) {
async loadItems(this: Persistent<Order>) {
// this.payments comes from the prototype chain — not a field
return this.payments.getItemsFor(this);
}
}Tip
JSON.stringify, Object.keys, or serialization. They're invisible infrastructure — methods can use them, but they never leak into your data.The Alternative: Anemic Models
Without models-as-services, you end up with the classic split:
// Anemic model — just data
interface Order { amount: number; status: string; }
// Separate service — behavior lives elsewhere
class OrderService {
constructor(private payments: PaymentService) {}
async loadItems(order: Order) {
return this.payments.getItemsFor(order);
}
validate(order: Order) {
return order.amount > 0;
}
}This works, but it scatters domain logic across files. The model doesn't know what it can do. The service doesn't own the data it operates on. In JustScale, the model IS the service — behavior and data are one object.