Code Agency
7 min read

One door to the ERP: why we put a NestJS API layer between Odoo and everything else

Storefront, mobile app, carrier webhooks, marketing tools — everything wants to talk to Odoo. Let them all speak JSON-RPC directly and every consumer inherits Odoo's internals. We give them one door instead.

Count the things that talk to a production Odoo besides its own web client: a headless storefront, a mobile app, a carrier pushing tracking updates, a payment provider confirming transactions, a marketing platform syncing contacts, the odd Zapier hook someone set up in 2023. Each of them needs data that lives in the ERP.

The tempting architecture is no architecture: give every consumer the JSON-RPC endpoint and an API key, and let them call res.partner and sale.order directly. It works on day one. It's also how you end up, eighteen months later, with six external systems hard-coupled to Odoo's internal data model — and an upgrade that can't touch a field without breaking a mobile app you don't control the release cycle of.

Our rule on these projects is simple: external consumers never speak JSON-RPC. They speak to one API layer, and only that layer speaks to Odoo. For the last few years that layer has been NestJS.

Odoo's API is a database with opinions, not a contract

JSON-RPC is honest about what it is: remote access to Odoo's ORM. You're not calling "get customer", you're reading res.partner — the actual model, with the actual fields, including the ones a community module bolted on last quarter. That's fantastic for internal tooling and exactly wrong as a public contract:

  • The model is the interface. Rename a field, install a module that shadows one, or move data in a version migration, and every external consumer breaks at once — usually at runtime, usually at night.
  • The permissions are ORM permissions. An API key that can read sale.order can read all of sale.order. Scoping "this consumer sees order status and nothing else" isn't expressible.
  • The latency is ERP latency. Every call runs real business logic on Odoo workers. Six consumers polling for stock is six times the load on the same workers that serve your sales team — the exact workers we spend so much effort not wasting.

None of this is an Odoo flaw. It's what an internal API looks like when it's asked to do an external API's job.

One door, one contract

The layer itself is a small NestJS service that owns the public contract. Consumers get versioned REST endpoints with explicit shapes; Odoo sees exactly one client, from one place, with one connection-pooling policy.

We pick NestJS for it deliberately, not by habit. The frontends we ship are React and TypeScript; a Nest layer means the API contract is TypeScript end to end — DTOs on the server, generated types in the storefront, one language for the whole team. And Nest's module system maps cleanly onto ERP domains: a CatalogModule, an OrdersModule, a WebhooksModule, each declaring what it needs and nothing else.

The contract work happens in DTOs, validated at the edge:

orders/dto/create-order.dto.ts
export class CreateOrderDto {
  @IsInt()
  productId: number
 
  @IsInt()
  @Min(1)
  @Max(500)
  quantity: number
 
  @IsOptional()
  @IsString()
  @MaxLength(500)
  note?: string
}

By the time a request reaches the service that talks to Odoo, it's shaped. Odoo never sees a negative quantity or a 2 MB "note" field — malformed input dies at the door with a 400 and a field-level message, without costing an ERP worker a single millisecond. The service then translates the clean request into ORM calls:

orders/orders.service.ts
async createOrder(dto: CreateOrderDto, customer: Customer) {
  const orderId = await this.odoo.execute("sale.order", "create", [{
    partner_id: customer.odooPartnerId,
    order_line: [[0, 0, {
      product_id: dto.productId,
      product_uom_qty: dto.quantity,
    }]],
  }])
  return this.getOrder(orderId) // returns the public shape, not the model
}

That last comment is the whole pattern. sale.order with its two hundred fields goes in; a ten-field Order the contract promised comes out. When Odoo 19 renames something, we change one mapper — not six consumers.

Translate errors, don't forward them

Odoo raises UserError when a business rule refuses an action, and AccessError when permissions do. Forward those raw and every consumer gets a 500 with a Python traceback — indistinguishable from the server being on fire. So the layer's exception filter does the reading:

odoo/odoo-exception.filter.ts
if (fault.includes("odoo.exceptions.UserError")) {
  // Business rule said no: the request was valid, the answer is "refused".
  throw new UnprocessableEntityException(extractUserMessage(fault))
}
if (fault.includes("odoo.exceptions.AccessError")) {
  throw new ForbiddenException("Not allowed for this account")
}
throw new BadGatewayException("ERP unavailable") // real fires stay 5xx

A frontend can act on that: a 422 renders the human message next to the button, a 5xx renders "try again later" and triggers a retry policy. This is the clean-422 contract our TanStack Query setup leans on to know refused from broken — refusals must never look like outages, or the UI retries a request the business already rejected.

Cheap answers stay in the layer

Once every external read funnels through one place, the load problem gets solved in that place — not six times, inconsistently, in six consumers. Catalogue-shaped endpoints get a cache interceptor with a TTL per domain, same reasoning as staleTime: how old may this answer be before it's wrong? Product data cached for minutes, stock for less, order status not at all. Per-consumer rate limits sit in front of that, so a misbehaving integration throttles itself instead of starving the workers your sales team is using.

Writes that don't need an immediate answer — sync jobs, imports, the carrier's fourth "still in transit" webhook this hour — don't call Odoo synchronously at all. They land in a SKIP LOCKED job queue on the Postgres we already run and a worker drains them at a rate Odoo is comfortable with. The webhook answers 202 in ten milliseconds; the ERP does the work on its own schedule.

What stays in Odoo

The layer translates, validates, caches, authenticates, and queues. What it must never do is decide. Pricing rules, credit limits, stock reservation, tax — that logic lives in Odoo, where the sales team's screens and the accountant's reports read the same rules. The moment somebody reimplements "10% volume discount" in a NestJS service because calling Odoo felt slow, there are two pricing engines, and they will disagree in the most expensive week of the year. If a rule is too slow to call, that's a reason to fix the rule or cache its output — never to fork it.

When you don't need this

One box less is one problem less, so the honest countercases, in the spirit of our microservices rule:

  • One internal consumer, read-only. A dashboard your own team uses can speak JSON-RPC directly. The coupling risk is real but you control both sides and both deploy dates.
  • A trivial integration. A website that shows three fields from the ERP doesn't need a NestJS service; a scheduled export does the job with zero moving parts.
  • No TypeScript in the house. The pattern matters more than the framework — a FastAPI layer gives a Python-first team the same one-door architecture.

The threshold to watch is the second external consumer, or the first one whose release cycle you don't control. That's the moment the ERP's internal API is becoming a public one by accident — and public APIs need contracts.

The rule of thumb

When we audit a custom web application around an ERP, the question isn't whether Odoo has an API — it's who's allowed to know what Odoo looks like inside. Our answer: exactly one service. Everything else gets a contract — validated at the edge, versioned, cached where the business allows it, with refusals that read as refusals. The ERP stays free to be an ERP, and to change like one.

Six consumers coupled to one door beats six consumers coupled to your data model — because doors are cheap to keep stable, and data models never are.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.