Part 5. Services and registries · lesson 25 of 40
Writing your own service
After this lesson you can: Write, register and use a service with dependencies and shared reactive state.
Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.
When two components need the same data, or something must outlive any single component, write a service.
The smallest one
// static/src/services/greeting_service.js
import { registry } from "@web/core/registry";
export const greetingService = {
start(env) {
return {
greet(name) {
return `Hello, ${name}`;
},
};
},
};
registry.category("services").add("owl_course.greeting", greetingService);
setup() {
this.greeting = useService("owl_course.greeting");
}
A service is an object with a start. Whatever start returns is what
useService gives out.
Dependencies
export const rateService = {
dependencies: ["orm", "notification"],
start(env, { orm, notification }) {
return {
async load(currency) {
try {
return await orm.call("res.currency", "get_rate", [[], currency]);
} catch (e) {
notification.add("Could not load rates", { type: "danger" });
return null;
}
},
};
},
};
Declared dependencies are started first and handed to you destructured. Not
declaring one and reaching for env.services.orm anyway usually works and
occasionally does not, depending on start order. Declare them.
start may be async, and the client waits for it. Keep that work small, because
it delays the whole web client booting.
Shared reactive state
The pattern worth copying. A service holding a reactive object gives every
component the same state, with updates.
// static/src/services/dashboard_filter_service.js
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const dashboardFilterService = {
dependencies: ["orm"],
start(env, { orm }) {
const state = reactive({
period: "month",
companyIds: [],
from: null,
to: null,
});
function setPeriod(period) {
const now = new Date();
state.period = period;
state.to = now;
state.from = period === "year"
? new Date(now.getFullYear(), 0, 1)
: new Date(now.getFullYear(), now.getMonth(), 1);
}
setPeriod("month");
return { state, setPeriod };
},
};
registry.category("services").add("owl_course.dashboard_filter", dashboardFilterService);
import { useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
setup() {
this.filters = useService("owl_course.dashboard_filter");
this.filterState = useState(this.filters.state); // subscribe
}
<p>Showing <t t-esc="filterState.period"/></p>
useService gets the service. useState on its state subscribes this
component. Both steps are needed: without the useState, you read values that
never update.
Every component doing this shares one object, so a filter bar calling
setPeriod re-renders every panel that read period, with no props and no
events.
A cache
export const partnerCacheService = {
dependencies: ["orm"],
start(env, { orm }) {
const cache = new Map();
const inFlight = new Map();
async function get(id) {
if (cache.has(id)) {
return cache.get(id);
}
if (inFlight.has(id)) {
return inFlight.get(id); // do not fetch twice
}
const promise = orm
.read("res.partner", [id], ["name", "email"])
.then(([rec]) => {
cache.set(id, rec);
inFlight.delete(id);
return rec;
});
inFlight.set(id, promise);
return promise;
}
return { get, invalidate: (id) => cache.delete(id), clear: () => cache.clear() };
},
};
The inFlight map is the part people leave out. Without it, twenty rows asking
for the same partner at once produce twenty identical requests, because none of
them has finished to populate the cache yet.
A cache needs an invalidation story. Here it is manual, which is honest. A cache with no way to invalidate is a bug waiting for someone to edit a record.
Polling, cleanly
export const alertService = {
dependencies: ["orm", "notification"],
start(env, { orm, notification }) {
const state = reactive({ count: 0 });
let timer = null;
async function poll() {
try {
state.count = await orm.silent.searchCount(
"my.alert", [["acknowledged", "=", false]],
);
} catch {
// a failed poll is not worth interrupting anyone over
}
}
function start(ms = 60000) {
stop();
poll();
timer = setInterval(poll, ms);
}
function stop() {
if (timer) { clearInterval(timer); timer = null; }
}
start();
return { state, start, stop, poll };
},
};
Two deliberate choices. orm.silent so background polling does not flicker the
loading indicator. And a failed poll is swallowed, because the network dropping
for a second should not throw a dialog at someone mid sentence.
A service lives for the whole page, so this interval is never cleaned up. That is correct here, and it is also why you should not create services casually: every one of them is permanent.
Service or store
A plain reactive object at module scope is lighter and fine for pure UI state
with no dependencies.
Use a service when you need the ORM or another service, when something must start at boot, or when you want it replaceable in tests. In Odoo code, a service is the convention, and matching the convention is worth something on its own.
Testing
Because components ask by name, a test can register a different implementation:
registry.category("services").add("owl_course.dashboard_filter", {
start: () => ({ state: reactive({ period: "year" }), setPeriod() {} }),
}, { force: true });
The component under test is unchanged. That is the whole argument for services over imports, and Part 7 uses it properly.
What is next
Part 6. Changing Odoo itself: patching components you do not own, and adding field widgets, client actions and view types.