Data modelling and serialisation
The casing problem
The backends speak snake_case; the application speaks camelCase. Sometimes.
Not always. This is the single most error-prone area of the codebase, and it is
worth understanding precisely.
cerialize provides the translation, but the casing setting is global and
imperative, not per-model:
// SupplierService.fetchSupplierDetails
return this.httpClient.get<SupplierDetailsModel>(url, { params }).pipe(
map((result) => {
DeserializeKeysFrom(CamelCase); // ← global switch, set per call
return Deserialize(result, SupplierDetailsModel);
}),
);
// CrmService.listActions
map((res) => {
DeserializeKeysFrom(UnderscoreCase); // ← the other direction
return { data: res['data'].map(/* … */), meta: Deserialize(res['meta'], CrmMetaModel) };
})
// PendingOfferRiskRatingService.prepareDataToSave
SerializeKeysTo(UnderscoreCase);
const serialized = Serialize(dataToSave, ProgressivePricingOfferModel);
|
|
Where a payload key genuinely does not follow either convention, the model keeps the
API’s own spelling — for example AirwallexCardholderStatus uses
connected_account_status, kyc_status, cardholder_terms_accepted verbatim, and
ProgressivePricingOfferDTO’s sibling `IAdvanceOfferRequest uses
advance_offer_request_key, weekly_fee_percent, and so on. That is intentional:
those interfaces describe the wire format, not the domain.
A small helper, shared/utils/to-lowercase-underscore.util.ts, converts display
labels to snake_case column names for report queries — unrelated to cerialize
but easy to confuse with it.
Three model styles
The codebase has accreted three generations of model. All three are live; match the style of the file you are editing, and prefer style 3 for new code.
1. Decorated cerialize classes
The dominant style in suppliers/model/. Serialisation is declared with
decorators, and derived business predicates live as getters.
export class SupplierDetailsModel {
@autoserialize public supplierKey: string;
@autoserialize public companyDBA: string;
@autoserializeAs(SupplierStatusModel) public status?: SupplierStatusModel;
@autoserializeIndexable(SupplierMarketplaceListModel)
public marketplaces?: SupplierMarketplaceListModel[];
constructor(dto?: ISupplierDetailsDTO) { Object.assign(this, dto); }
get isTarget(): boolean { /* any marketplace is the Target marketplace */ }
get isAmazonVendor(): boolean { /* Amazon Vendor Central or Express */ }
get isAmazonSeller() { /* count of Amazon Seller Central links */ }
}
Decorators in use:
| Decorator | Meaning |
|---|---|
|
Primitive or plain object; participates in both directions. |
|
Nested model instance. |
|
Array/indexable collection of a model type. |
|
Class-level; inherits the base class’s serialisation metadata (used by
|
Note the paired DTO interface convention: ISupplierDetailsDTO mirrors the
class’s fields as all-optional, and the constructor takes it and Object.assign`s.
That gives callers a cheap way to build a partial model without the decorators
getting in the way — which is exactly what the `UPDATE_MODEL_PART_SUCCESS reducer
does:
const updatedDetailsModel = new SupplierDetailsModel(supplierModel.supplierDetailsModel);
Object.assign(updatedDetailsModel, action.payload.newValues);
|
Business getters on models (
|
2. Plain classes with a DTO constructor
Used across reports/, marketplaces/ and processes/. No decorators; the
constructor maps an incoming DTO.
export class ExternalReport implements IExternalReport {
constructor(dto: IExternalReportDTO) { /* explicit field mapping */ }
}
Services then do response.map(item ⇒ new ExternalReport(item)). Slightly more
verbose than decorators but with no global-state hazard, and easy to read.
3. Interfaces plus pure mapper functions
The newest style, best exemplified by
src/app/suppliers/model/card.model.ts. Types describe the wire format and
the domain separately; free functions map and derive.
export type CardProvider = 'marqeta' | 'airwallex';
export interface CardBase { provider: CardProvider; cardId: string; status: string; lastFour?: string; }
export interface MarqetaCard extends CardBase { provider: 'marqeta'; /* … */ }
export interface AirwallexCard extends CardBase { provider: 'airwallex'; formFactor: 'VIRTUAL' | 'PHYSICAL'; /* … */ }
export type SupplierCard = MarqetaCard | AirwallexCard; // discriminated union
export function mapMarqetaCard(raw: any): MarqetaCard { /* … */ }
export function mapSupplierCard(raw: any): SupplierCard {
return raw.provider === 'airwallex' ? mapAirwallexCard(raw) : mapMarqetaCard(raw);
}
export function isCardBlocked(card: SupplierCard): boolean {
return card.provider === 'marqeta' ? card.status === 'SUSPENDED' : card.status === 'INACTIVE';
}
export function deriveAirwallexState(status): AirwallexOnboardingState { /* state machine */ }
This style is preferred for new work because:
-
Provider-specific status vocabularies are normalised in one place (
STATUS_MAP,normalizeCardStatus,normalizeCardId). -
The predicates are pure and trivially unit-testable (
card.model.spec.ts). -
A discriminated union makes the compiler enforce provider handling.
See card programmes for the business meaning of this particular model.
Model, DTO, interface — which to write
| Kind | Use for |
|---|---|
|
The exact wire shape, with the backend’s own key spelling. No behaviour. |
|
The domain shape consumed by components. Keys in |
|
When you need |
|
When a payload is polymorphic and a discriminant field exists. |
|
Closed vocabularies. Placed in |
One concept per file, named <concept>.model.ts, in the owning module’s model/
folder. There are ~80 model files.
Polymorphic deserialisation
CrmService.listActions demonstrates the app’s one hand-rolled polymorphic mapper:
a single activity feed returns mixed types and is dispatched on item.type.
switch (item.type) {
case 'note': return Deserialize(item, NoteModel);
case 'task': return Deserialize(item, TaskModel);
case 'event': return Deserialize(item, EventModel);
case 'file': return Deserialize(item, FileModel);
case 'email':
case 'email_supplier': return Deserialize(item, EmailModel);
case 'batch': return Deserialize(item, EmailModel);
default: return Deserialize(item, BaseCrmModel);
}
BaseCrmModel is the fallback, so an unknown activity type degrades to a generic
timeline entry rather than throwing. Adding a CRM item type means touching this
switch, the CrmStateModel streams and the CrmItem union type. See
CRM.
Response envelopes
Backends are not consistent. Know the four shapes you will meet:
| Envelope | Where |
|---|---|
Bare array/object |
Internal API lookups ( |
|
Internal API list endpoints (suppliers via |
|
Actions Tools, event rules, global settings. Typed as |
Elasticsearch hit envelope |
|
Services are responsible for unwrapping the envelope so that components and reducers only ever see models.
Aggregate models
Two classes are aggregates rather than payload mirrors — they exist only in the store and are assembled from several calls:
SupplierModel-
Holds
supplierDetailsModel,currentFundingParticularsModel,marketplaces,marketplaceFundingModels,marketplaceOptInPeriodModels,marketplaceMinFeeOverridesModels,paymentConfigsModels,balance,kycDocumentsStatuses,complianceAmazonAccessStatus,efsCardRequested. The maps are keyed bympSupKey/paymentConfigKey. Assembled bySupplierEffects.getSupplierDetailsand friends. SupplierMarketplaceModel-
The same idea one level down: the marketplace link plus its funding, min-fee overrides and opt-in periods.
Neither has cerialize decorators, because neither is ever sent to a backend.
Validators as domain logic
A few validators encode business rules and are worth knowing about because they live outside the models:
-
suppliers/supplier/supplier-payment/routing-num.validator.ts— aValidatorFnfactory keyed on the routing-number verification status. -
suppliers/enums/routing-number-validation-status.enum.ts
routing-number-validation-status.config.model.ts— status → message/severity mapping (success/danger/warning), includingINSUFFICIENT_FOR_NATIONAL_PAYMENTandINSUFFICIENT_FOR_INTERNATIONAL_PAYMENT. -
manage/event-rules/rules-add-edit/action-form/send-to-event-bridge/validators/event-bridge-action-name.validator.ts. -
manage/event-rules/event-rule.models.ts— theFilterclass, which parses and renders filter expressions (see events).