orm Service – The Right Tool for Model Calls
The orm service wraps Odoo's JSON-RPC ORM endpoint (/web/dataset/call_kw) with a clean, typed API. Always prefer it over raw rpc for model operations.
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
setup() {
this.orm = useService("orm");
}
Odoo Domain Syntax in JavaScript
Domains are arrays of conditions. Each condition is a 3-tuple: [fieldName, operator, value]. Conditions are AND'd by default; prefix with "&" or "|" for explicit operators.
// Simple AND (default): all conditions must match
const domain = [
["is_company", "=", true],
["active", "=", true],
["country_id", "=", 233], // 233 = United States ID
];
// OR: either condition matches (prefix with "|")
const orDomain = [
"|",
["state", "=", "draft"],
["state", "=", "sent"],
];
// Mixed AND/OR
const mixedDomain = [
"&",
["is_company", "=", true],
"|",
["country_id", "=", 233],
["country_id", "=", 76], // France
];
// Common operators
// "=" "!=" "<" ">" "<=" ">="
// "like" "ilike" (case-insensitive)
// "in" "not in" — value is an array
// "child_of" "parent_of" — hierarchical
// "=" false or "=", [] — empty/unset many2many
const searchDomain = [["name", "ilike", this.state.searchQuery]];
const inDomain = [["id", "in", [1, 5, 12, 44]]];
const dateDomain = [
["date_order", ">=", "2026-01-01"],
["date_order", "<", "2027-01-01"],
];
searchRead – The Most Common ORM Call
const records = await this.orm.searchRead(
"sale.order", // model technical name
[ // domain (array of conditions)
["state", "in", ["draft", "sent"]],
["user_id", "=", this.user.userId],
],
[ // fields to fetch (empty array = all)
"name", "partner_id", "amount_total", "state", "date_order"
],
{
limit: 50, // max records (default: no limit in some contexts)
offset: 0, // for pagination
order: "date_order desc", // sort
context: {}, // additional context dict
}
);
// Returns: [{ id: 1, name: "S001", partner_id: [5, "Alice"], ... }, ...]
// Note: many2one fields return [id, display_name]
Calling Custom Python Methods
// JavaScript: call a custom Python model method
const result = await this.orm.call(
"sale.order", // model
"action_confirm", // Python method name
[[orderId]], // positional args — first must be the IDs list
{ context: {} } // optional keyword args
);
// For a classmethod (no IDs):
const stats = await this.orm.call(
"sale.order",
"get_sales_stats",
[], // empty ids list
{ date_from: "2026-01-01", date_to: "2026-12-31" }
);
from odoo import models, api
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = "sale.order"
def action_confirm(self):
# 'self' is a recordset of the IDs passed from JS
for order in self:
if not order.order_line:
raise UserError("Cannot confirm an order with no lines.")
return super().action_confirm()
@api.model
def get_sales_stats(self, date_from, date_to):
# @api.model: called without a recordset (empty IDs list from JS)
orders = self.search([
("date_order", ">=", date_from),
("date_order", "<", date_to),
("state", "=", "sale"),
])
return {
"count": len(orders),
"revenue": sum(orders.mapped("amount_total")),
}
Handling Server Errors
async confirmOrder(orderId) {
try {
await this.orm.call("sale.order", "action_confirm", [[orderId]]);
this.notification.add("Order confirmed!", { type: "success" });
} catch (err) {
// Odoo error structure:
// err.message — generic "Odoo Server Error"
// err.data.name — "odoo.exceptions.UserError"
// err.data.message — human-readable message from Python
if (err.data?.name === "odoo.exceptions.UserError") {
// Show the message from the Python UserError
this.notification.add(err.data.message, { type: "warning" });
} else if (err.data?.name === "odoo.exceptions.AccessError") {
this.notification.add("You do not have permission for this action.", { type: "danger" });
} else {
// Unexpected server error — show generic message + log
console.error("Unexpected error:", err);
this.notification.add("An unexpected error occurred. Please contact your administrator.", {
type: "danger",
sticky: true,
});
}
}
}
rpc Service – Custom Controllers
For non-ORM endpoints — custom JSON/HTTP controllers you wrote — use the lower-level rpc service.
// JavaScript
setup() {
this.rpc = useService("rpc");
}
async getDashboardData() {
const data = await this.rpc("/my_addon/dashboard_data", {
company_id: this.user.activeCompany.id,
});
this.state.stats = data;
}
from odoo import http
from odoo.http import request
import json
class DashboardController(http.Controller):
@http.route("/my_addon/dashboard_data", type="json", auth="user")
def dashboard_data(self, company_id=None, **kwargs):
env = request.env
orders = env["sale.order"].search_count([
["company_id", "=", company_id],
["state", "=", "sale"],
])
return {
"confirmed_orders": orders,
"revenue": env["sale.order"].search([
["company_id", "=", company_id],
["state", "=", "sale"],
]).mapped("amount_total"),
}
📋 Summary
- Use
ormservice for all standard model operations. Userpcservice only for custom controller routes. - Domains are arrays of
[field, operator, value]triples. Use"|"prefix for OR,"&"for explicit AND. orm.searchRead(model, domain, fields, options)— most common call. Many2one fields return[id, name]arrays.orm.call(model, method, [ids], kwargs)— first positional arg must be the IDs list; empty list for@api.modelmethods.- Server errors carry
err.data.name(exception class) anderr.data.message(human text). Always checkdata.nameto distinguish UserError from access errors from unexpected errors. - Custom controllers:
type="json",auth="user". Call viarpc("/route", payload).
🏋️ Exercise
Build a complete Odoo OWL component that reads and writes via the backend:
- Create a Python model method
get_overdue_invoices()that returns a count and total amount of overdue invoices for the current user's company. - Create an OWL component that calls it via
orm.callinonWillStartand displays the stats. - Add a "Send Reminder" button that calls
orm.callwith anaccount.movemethod to email reminders. Handle UserError gracefully. - Add a domain filter (All / This Month / Overdue) that changes the
searchReaddomain and re-fetches the list without remounting. - Use
notification.addfor success/failure feedback after every backend call.
FAQ
orm.call internally calls /web/dataset/call_kw with the model/method/args structure that Odoo's ORM expects. It handles authentication, CSRF tokens, and session cookies for you. Direct rpc("/custom/route", data) calls your custom URL with a raw POST — you control the route and response format. Use orm.call for everything that goes through the Odoo ORM. Use raw rpc only for non-ORM endpoints.
Odoo's ORM always returns many2one fields as a 2-tuple [id, display_name] — e.g., partner_id: [5, "Alice Corp"]. To get just the ID use record.partner_id[0]; for the name use record.partner_id[1]. If you request a field like partner_id.name (dot notation) in the fields list, Odoo returns it as a flat string instead of the tuple. This is useful when you only need the name.