Skip to main content

Backend vs Frontend Models

Models exist on both sides of an Oidis application. They share the same decorator system (@Entity, @Property) and base class (BaseModel<T>), but have different capabilities and serve different roles.

Side-by-Side Comparison​

AspectBackend (Models/)Frontend (Gui/Models/)
Base classBaseModel<T> from commonsBaseModel<T> from commons, or a hub-gui model (User, Group, …)
@EntityRequired — maps to MongoDB collectionRequired for DB-mapped models; omitted for FE-only models
DB accessFull (Find, Save, Delete, Update) on MongoDBCross-tier data flows through connectors. Local-only models with IndexedDbContext get full Find / Save / Refresh / Remove against the browser's IndexedDB — see IndexedDB.
ConvertersHandle both DB reads and LCP receivesPrimarily handle LCP deserialization (dataTranslation = true)
Server-only fieldsnoJSON: true keeps a field out of ToJSON() / ToInterface()Not applicable
Locationsource/.../Models/source/.../Gui/Models/
Converters locationsource/.../Models/Converters/source/.../Gui/Models/Converters/

Backend Models​

Backend models are the source of truth. They define the database schema, handle persistence, and manage data integrity.

// source/typescript/Io/Oidis/YourProject/Models/ProductRecord.ts
import { Entity, Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { BaseModel } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/BaseModel.js";

@Entity("products")
export class ProductRecord extends BaseModel<ProductRecord> {

@Property({type: String})
public declare Code : string;

@Property({type: ProductGroup, required: false, converter: new ProductGroupToRefConverter()})
public declare Group : ProductGroup;

@Property({type: Number, required: false})
public declare TaxRate : number;

// Server-only: excluded from ToJSON() / ToInterface()
@Property({type: String, required: false, noJSON: true})
public declare InternalNote : string;
}

Backend models can:

  • Query MongoDB: ProductRecord.Find({...}), ProductRecord.FindById($id)
  • Persist data: record.Save(), record.Update("TaxRate") ($set of the current value of one property)
  • Soft delete: record.Delete()
  • Validate: record.Validate(), record.ValidateDeep()
  • Serialize for transport: record.ToJSON()

Frontend Models​

Frontend models that mirror a backend MongoDB collection do not talk to MongoDB directly — the data flows through connectors via LCP. The exception is models whose context is IndexedDbContext: those are persisted locally in the browser's IndexedDB and expose the same CRUD surface (Find, Save, Refresh, Remove) on the frontend. See IndexedDB for the open lifecycle and version handling.

// source/typescript/Io/Oidis/YourProject/Gui/Models/ProductRecord.ts
import { Entity, Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
import { BaseModel } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/BaseModel.js";

@Entity("products")
export class ProductRecord extends BaseModel<ProductRecord> {

@Property({type: String})
public declare Code : string;

@Property({type: ProductGroup, required: false, converter: new ProductGroupToRefConverter()})
public declare Group : ProductGroup;

@Property({type: Number, required: false})
public declare TaxRate : number;

// InternalNote is NOT here — it is excluded via noJSON on the backend
}

Key differences:

  • Uses the same commons BaseModel<T>, but lives in Gui/Models/ and is compiled for the browser
  • No InternalNote field — it was excluded from serialization on the backend
  • Converters use the dataTranslation pattern (always receiving serialized objects)
  • No direct MongoDB access — backend data flows through asyncInvoke() on connectors. Models with IndexedDbContext instead persist locally in the browser (IndexedDB).

Frontend-Only Models​

Some frontend models don't map to a database collection at all. They extend hub-gui base models and add project-specific fields:

// Frontend-only model — NO @Entity
import { User as HubUser } from "@io-oidis-hub-gui/.../Models/User.js";

export class User extends HubUser {

// Project-specific fields added on top of hub's User model
@Property({type: String, required: false})
public declare CompanyName : string;

@Property({type: String, required: false})
public declare ICO : string;

@Property({type: Boolean, required: false})
public declare IsProducer : boolean;
}

Rules for FE-only models:

  • No @Entity — They don't own a MongoDB collection
  • Extend hub-gui base — Inherit standard fields from the hub model
  • Add project fields — Extend with application-specific properties
  • Never persist directly — All CRUD operations go through backend connectors

Converter Differences​

Backend Converter​

Handles both contexts — DB reads (stored id string) and LCP receives (serialized object):

// Models/Converters/ProductGroupToRefConverter.ts
export class ProductGroupToRefConverter extends BaseToRefConverter {

public async from($value : any, $context : IContext) : Promise<ProductGroup> {
if ($context.dataTranslation) {
return ProductGroup.From($value); // LCP: reconstruct from object
}
return ProductGroup.FindById($value); // DB: load by id
}
}

Frontend Converter​

Primarily handles LCP deserialization, but maintains the same pattern for consistency:

// Gui/Models/Converters/ProductGroupToRefConverter.ts
export class ProductGroupToRefConverter extends BaseToRefConverter {

public async from($value : any, $context : IContext) : Promise<ProductGroup> {
if ($context.dataTranslation) {
return ProductGroup.From($value); // LCP: reconstruct from object
}
return ProductGroup.FindById($value); // Hit only on the IndexedDB persistence path
}
}

The code looks nearly identical, but the frontend converter lives in Gui/Models/Converters/ and imports the frontend version of ProductGroup.

Data Flow​

┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ │
│ Controller → Connector.asyncInvoke("ListRecords") │
│ │ │
│ ▼ LCP │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Backend │ │
│ │ │ │
│ │ @Extern ListRecords() │ │
│ │ → BE Model: ProductRecord.Find({...}) │ │
│ │ → MongoDB query (converter: id → instance) │ │
│ │ → record.ToJSON() (serialization) │ │
│ │ → Response (JSON) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ LCP response │
│ FE Connector receives serialized data │
│ → FE Model converter (dataTranslation = true) │
│ → Model.From(data) → typed FE model instance │
│ → Controller updates UI │
└─────────────────────────────────────────────────────────────┘

When to Skip Backend Models​

For simple data entities (e.g. registries, lookup tables), a dedicated backend model is not required. The backend DataManager can import and use the frontend model directly, because both sides share the same @Entity and @Property decorator system and the same BaseModel<T> base class.

When a backend model IS needed:

  • The model extends a complex hub base class (e.g. User extends Hub.DAO.Models.User)
  • Server-only fields are required (noJSON: true)
  • Custom backend-specific logic (hooks, computed properties, aggregation helpers)

When a backend model is NOT needed:

  • Simple CRUD entities with no server-only fields
  • All properties are shared between frontend and backend
  • The backend only performs basic Find, SyncWith, Save, Remove operations

In this case, define the model only in Gui/Models/ and import it in the backend DataManager:

// Utils/DataManager.ts — backend imports FE model directly
import { Company } from "../Gui/Models/Company.js";

@Extern()
public async SaveCompany($data : Company) : Promise<void> {
let model : Company = await Company.FindById($data.Id);
if (ObjectValidator.IsEmptyOrNull(model)) {
delete $data.Id;
model = new Company();
}
model.SyncWith($data);
await model.Save();
}

This approach simplifies maintenance — there is only one model definition to keep in sync, and SyncWith works seamlessly because the same @Property declarations are used on both sides.

SyncWith copies every field

SyncWith($data) copies all properties present in $data, including fields the user must not control (owner, organization, flags). The @Extern method is callable by every user allowed to call it, so mark such fields protected: true or assign the permitted fields explicitly when the input comes from a client — see Protected Properties.

tip

The User model is a notable exception. It extends the Hub's User base class which contains complex authentication, session, and permission logic. Always create a backend User model in Models/User.ts that extends @io-oidis-hub/...Hub/DAO/Models/User.js.

Checklist: Adding a New Model​

  1. Decide if a backend model is needed — see criteria above; most simple entities only need a FE model
  2. Frontend model — @Entity, @Property declarations in Gui/Models/, converters in Gui/Models/Converters/
  3. Backend model (if needed) — Mirror in Models/, add noJSON for sensitive fields and protected for fields only BE code may write, converters in Models/Converters/
  4. Backend manager — Import the model (FE or BE) and implement CRUD with @Extern() methods
  5. ForceCompile — Add import for the FE model; Builder auto-adds controller references
  6. Connector — Add typed methods in the GUI connector wrapping asyncInvoke()

See also: Models Overview | Converters | Validators | IndexedDB