Warming up the neural circuits...
By the end of this chapter you will:
A model is a class that represents one database table. It's how your code talks to the database — but it must remain a thin mirror of the schema, not a dumping ground for business logic.
Think of it like this:
If they disagree, the database wins. The model is just your code's best guess about what the table looks like.
Let's model the orders table we designed in Chapter 5.
// src/modules/orders/entities/order.entity.ts
import {
Table, Column, Model, PrimaryKey, Default, AllowNull,
DataType, ForeignKey, BelongsTo, HasMany, CreatedAt, UpdatedAt,
} from 'sequelize-typescript';
import { User } from '../../users/entities/user.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
// Step 1 — Define status values as a TypeScript enum
export enum OrderStatus {
Pending = 'pending',
Confirmed = 'confirmed',
Preparing = 'preparing',
OutForDelivery = 'out_for_delivery',
Delivered = 'delivered',
Cancelled = 'cancelled',
}
// Step 2 — Declare the table this class maps to
@Table({
tableName: 'orders',
underscored: true, // maps camelCase properties to snake_case columns
timestamps: true, // expects created_at and updated_at columns
})
export class Order extends Model<Order> {
@PrimaryKey
@Default(DataType.UUIDV4)
@Column(DataType.UUID)
id: string;
@ForeignKey(() => User)
@AllowNull(false)
@Column(DataType.UUID)
user_id: string;
@BelongsTo(() => User)
user: User;
@ForeignKey(() => Restaurant)
@AllowNull(false)
@Column(DataType.UUID)
restaurant_id: string;
@BelongsTo(() => Restaurant)
restaurant: Restaurant;
@AllowNull(false)
@Default(OrderStatus.Pending)
@Column(DataType.STRING(20))
status: OrderStatus;
// ⚠️ PostgreSQL returns DECIMAL/NUMERIC as a STRING in Node.js.
// This is intentional — it preserves full precision. Keep it as a string.
@AllowNull(false)
@Column(DataType.DECIMAL(10, 2))
total_amount: string;
@CreatedAt created_at: Date;
@UpdatedAt updated_at: Date;
}| Decorator | What it does |
|---|---|
@Table({ tableName }) | Links the class to a specific DB table |
@Column(DataType.X) | Maps this property to a column with the given type |
@PrimaryKey | Marks as the primary key |
@Default(value) | Sets a default value when creating a new row |
@AllowNull(false) | Makes the column NOT NULL |
@ForeignKey(() => OtherModel) | Marks this as a |
@BelongsTo(() => OtherModel) | Defines the many-to-one relationship |
@HasMany(() => OtherModel) | Defines the one-to-many relationship |
@CreatedAt | Automatically set to now() on create |
@UpdatedAt | Automatically updated to now() on update |
// In User model:
@HasMany(() => Order)
orders: Order[];
// In Order model:
@ForeignKey(() => User)
@Column(DataType.UUID)
user_id: string;
// In Order model:
@BelongsToMany(() => Tag, () => OrderTag)
tags: Tag[];
// OrderTag is the junction model:
@Table({ tableName: 'order_tags' })
export class OrderTag extends Model
const order = await this.orderModel.findByPk('abc-123');
// If not found, returns nullconst order = await this.orderModel.findOne({
where: { user_id: 'usr-123', status: OrderStatus.Pending },
});const orders = await this.orderModel.findAll({
where: {
user_id: 'usr-123',
status: { [Op.in]: [OrderStatus.Pending, OrderStatus.Confirmed
const order = await this.orderModel.findByPk('ord-123', {
include: [
{ model: User, attributes: ['id', 'name', 'email'] },
{ model:
Loading include: [everything] is the #1 cause of slow APIs in this project. Specify attributes to limit which columns are loaded.
const order = await this.orderModel.create({
user_id: user.id,
restaurant_id: dto.restaurantId,
total_amount: dto.totalAmount,
// status defaults to 'pending' via model default
})
await order.update({ status: OrderStatus.Confirmed });
// OR update by condition
await this.orderModel.update(
{ status: OrderStatus.Approved },
{ where: { id:
This is critical. When multiple writes must succeed or fail together, pass { transaction: tx } to every call:
await sequelize.transaction(async (tx) => {
const order = await this.orderModel.create(
{ user_id: user.id, restaurant_id: dto.restaurantId, total_amount: dto
Calling .create() or .update() without { transaction: tx } inside a transaction block. That write is NOT part of the transaction and will NOT roll back if something fails after it.
For simple endpoints, inject and use the model directly in your service. For complex queries (cursor , multiple joins, dynamic conditions), extract a repository class:
// src/modules/orders/orders.repository.ts
@Injectable()
export class OrdersRepository {
constructor(
@InjectModel(Order) private readonly model: typeof Order,
) {}
async findPendingByUser(userId: string, cursor: Date
Benefits:
OrdersRepository instead of the Sequelize modelunderscored: true so column names match snake_case automatically@CreatedAt / @UpdatedAt decoratorsenum for finite-value columns (status, type, etc.)total_amount and other NUMERIC columns as string in TypeScriptSequelizeModule.forFeature([Order])order.canBeCancelled() — that's a service)include: [everything] — only load what this endpoint actually usesWhen you create a new entity (foo.entity.ts), you must:
src/modules/foo/entities/foo.entity.tsSequelizeModule.forFeature([Foo]) in foo.module.ts@InjectModel(Foo) private readonly fooModel: typeof FooIf you skip step 3, NestJS throws "FooModel is not a registered repository" at startup.
The model is a thin mirror of the table — it just reflects columns and relationships. If it has methods with business logic, you've broken the layered architecture. Move that logic to the service.