Why this chapter? A single
notification.add()call is easy; coordinating three services in one workflow, keeping a search box from hammering the server on every keystroke, and recovering from a flaky network call are the things that actually eat a workday once an addon grows past its first version. What is Odoo trying to solve with this? Odoo's UI has to stay responsive and resilient under real usage — spotty connections, slow endpoints, users who click twice — so the framework leans on patterns like debouncing, caching, and structured retries instead of leaving every developer to reinvent them. Real-world application: Debounced search, retry-with-backoff on a flaky external API call, and multi-step workflows spanning several services are patterns I reach for constantly once a client addon moves past its first proof-of-concept.
In Part 1, we covered the services you'll use every day — notification, action, dialog, and a few essentials — along with the common mistakes junior developers run into with them. Now we'll tackle more demanding scenarios: composing several services into a single workflow, keeping expensive service calls fast, and recovering gracefully when a call fails.
Advanced Service Patterns
Pattern 3: Service Composition for Complex Workflows
async processCustomerOrder(orderData) {
const steps = [
{ name: "Validating order data", action: () => this.validateOrderData(orderData) },
{ name: "Creating customer", action: () => this.createCustomer(orderData.customer) },
{ name: "Creating order", action: () => this.createOrder(orderData) },
{ name: "Sending confirmation", action: () => this.sendConfirmationEmail(orderData) }
];
let completedSteps = 0;
try {
// Initial progress notification
this.notification.add(`Starting order processing (${steps.length} steps)...`, {
type: "info"
});
for (const step of steps) {
console.log(`Processing step: ${step.name}`);
// Execute step
await step.action();
completedSteps++;
// Progress notification
this.notification.add(
`${step.name} completed (${completedSteps}/${steps.length})`,
{ type: "info" }
);
}
// Success notification and navigation
this.notification.add("Order processed successfully!", {
type: "success",
sticky: false
});
// Navigate to the order
this.action.doAction({
type: "ir.actions.act_window",
res_model: "sale.order",
res_id: orderData.orderId,
views: [[false, "form"]],
target: "current"
});
} catch (error) {
console.error(`Order processing failed at step ${completedSteps + 1}:`, error);
this.notification.add(
`Order processing failed at step: ${steps[completedSteps]?.name || 'Unknown'}. ${error.message}`,
{ type: "danger", sticky: true }
);
// Optionally show rollback dialog
this.dialog.add(ConfirmationDialog, {
title: "Processing Failed",
body: "Would you like to rollback the changes made so far?",
confirm: () => this.rollbackChanges(completedSteps),
cancel: () => console.log("User chose not to rollback")
});
}
}
Pattern 4: Service-Based State Management
This pattern combines several services inside one component: useState for local reactive data, and orm/action/notification/dialog/user coordinated together. We'll build CustomerDashboard in three pieces.
First, the constructor-like setup(): state, service references, and a small dataLoader object that groups the read-only queries the dashboard needs:
export class CustomerDashboard extends Component {
static template = "my_module.CustomerDashboard";
setup() {
this.state = useState({
customers: [],
selectedCustomer: null,
loading: false,
filters: {
active: true,
country: null,
category: null
}
});
// Initialize services
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.user = useService("user");
// Service-based data loader
this.dataLoader = {
customers: () => this.orm.searchRead(
"res.partner",
this.buildCustomerDomain(),
["name", "email", "phone", "country_id", "category_id"],
{ order: "name asc" }
),
countries: () => this.orm.searchRead(
"res.country",
[],
["name", "code"],
{ order: "name asc" }
),
categories: () => this.orm.searchRead(
"res.partner.category",
[],
["name", "color"],
{ order: "name asc" }
)
};
// Initialize dashboard
useEffect(
() => {
this.initializeDashboard();
},
() => []
);
// Reactive filter updates
useEffect(
() => {
this.refreshCustomers();
},
() => [this.state.filters.active, this.state.filters.country, this.state.filters.category]
);
}
Next, the method that runs once on mount, loading all three datasets in parallel and reporting success or failure:
async initializeDashboard() {
try {
this.state.loading = true;
// Load all required data in parallel
const [customers, countries, categories] = await Promise.all([
this.dataLoader.customers(),
this.dataLoader.countries(),
this.dataLoader.categories()
]);
this.state.customers = customers;
this.state.countries = countries;
this.state.categories = categories;
this.notification.add("Dashboard loaded successfully!", {
type: "success"
});
} catch (error) {
console.error("Dashboard initialization failed:", error);
this.notification.add("Failed to load dashboard data.", {
type: "danger",
sticky: true
});
} finally {
this.state.loading = false;
}
}
Finally, the bulk-action methods: a lookup table of possible actions (archive, delete, export), a confirmation step for the dangerous ones, and the method that actually runs the chosen action:
// Service-orchestrated actions
async performBulkAction(action, customerIds) {
const actionMap = {
archive: {
title: "Archive Customers",
method: () => this.orm.write("res.partner", customerIds, { active: false }),
successMessage: `${customerIds.length} customers archived successfully`
},
delete: {
title: "Delete Customers",
method: () => this.orm.unlink("res.partner", customerIds),
successMessage: `${customerIds.length} customers deleted successfully`,
dangerous: true
},
export: {
title: "Export Customers",
method: () => this.action.doAction({
type: "ir.actions.report",
report_name: "base.report_partner_list",
context: { active_ids: customerIds }
}),
successMessage: "Customer export initiated"
}
};
const actionConfig = actionMap[action];
if (!actionConfig) return;
// Show confirmation for dangerous actions
if (actionConfig.dangerous) {
this.dialog.add(ConfirmationDialog, {
title: `Confirm ${actionConfig.title}`,
body: `This will permanently ${action} ${customerIds.length} customers. This action cannot be undone.`,
confirm: () => this.executeBulkAction(actionConfig)
});
} else {
await this.executeBulkAction(actionConfig);
}
}
async executeBulkAction(actionConfig) {
try {
this.notification.add(`${actionConfig.title} in progress...`, {
type: "info"
});
await actionConfig.method();
this.notification.add(actionConfig.successMessage, {
type: "success"
});
// Refresh data after bulk action
await this.refreshCustomers();
} catch (error) {
console.error(`${actionConfig.title} failed:`, error);
this.notification.add(`${actionConfig.title} failed: ${error.message}`, {
type: "danger",
sticky: true
});
}
}
}
Service Performance Optimization
Debounced Service Calls
Debouncing means delaying a function call until a burst of triggering events has stopped for a set amount of time — for example, waiting until the user pauses typing before firing a search request, instead of sending one request per keystroke. It's a common technique for keeping expensive service calls (like an ORM search) from overwhelming the server.
setup() {
this.orm = useService("orm");
// Debounce expensive searches
this.debouncedSearch = this.debounce(async (searchTerm) => {
if (!searchTerm.trim()) return;
try {
const results = await this.orm.searchRead(
"res.partner",
[["name", "ilike", searchTerm]],
["name", "email"],
{ limit: 10 }
);
this.state.searchResults = results;
} catch (error) {
console.error("Search failed:", error);
}
}, 300); // Wait 300ms after user stops typing
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
onSearchInput(event) {
const searchTerm = event.target.value;
this.state.searchTerm = searchTerm;
this.debouncedSearch(searchTerm);
}
Odoo already ships a ready-made debounce helper, so in real addons you rarely need to write your own: import { debounce } from "@web/core/utils/timing";.
Service Response Caching
setup() {
this.orm = useService("orm");
this.cache = new Map();
this.cacheExpiry = new Map();
this.getCachedData = async (cacheKey, fetcher, ttl = 5 * 60 * 1000) => {
// Check if we have valid cached data
if (this.cache.has(cacheKey)) {
const expiry = this.cacheExpiry.get(cacheKey);
if (Date.now() < expiry) {
console.log(`Using cached data for: ${cacheKey}`);
return this.cache.get(cacheKey);
}
}
// Fetch fresh data
console.log(`Fetching fresh data for: ${cacheKey}`);
const data = await fetcher();
// Cache the result
this.cache.set(cacheKey, data);
this.cacheExpiry.set(cacheKey, Date.now() + ttl);
return data;
};
}
async loadCountries() {
return this.getCachedData(
'countries',
() => this.orm.searchRead("res.country", [], ["name", "code"]),
10 * 60 * 1000 // Cache for 10 minutes
);
}
Service Error Recovery Strategies
Automatic Retry with Exponential Backoff
Exponential backoff means waiting longer between each retry attempt (1s, then 2s, then 4s, and so on) instead of retrying immediately — this gives a struggling server room to recover instead of hammering it with instant retries.
async performResilientOperation(operation, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await operation();
if (attempt > 1) {
this.notification.add("Operation succeeded after retry.", {
type: "success"
});
}
return result;
} catch (error) {
lastError = error;
console.warn(`Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt - 1) * 1000; // Exponential backoff
this.notification.add(`Operation failed, retrying in ${delay/1000}s... (${attempt}/${maxRetries})`, {
type: "warning"
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// All retries failed
this.notification.add(`Operation failed after ${maxRetries} attempts: ${lastError.message}`, {
type: "danger",
sticky: true
});
throw lastError;
}
// Usage
async saveData() {
await this.performResilientOperation(async () => {
return await this.orm.create("model.name", [this.state.formData]);
});
}
Real-World Integration Example
Here's a complete example showing how multiple services work together in a production scenario. We'll look at it in two pieces: the setup and data loading, then the action that processes selected orders.
export class OrderProcessingCenter extends Component {
static template = "my_module.OrderProcessingCenter";
setup() {
this.state = useState({
orders: [],
processing: false,
selectedOrders: new Set(),
statistics: {
total: 0,
pending: 0,
processing: 0,
completed: 0
}
});
// Service initialization
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.user = useService("user");
// Load data on mount
useEffect(
() => {
this.loadOrders();
this.loadStatistics();
},
() => []
);
}
async loadOrders() {
try {
const orders = await this.orm.searchRead(
"sale.order",
[["state", "in", ["draft", "sent", "sale"]]],
["name", "partner_id", "amount_total", "state", "date_order"],
{ order: "date_order desc", limit: 100 }
);
this.state.orders = orders;
} catch (error) {
this.notification.add("Failed to load orders.", { type: "danger" });
}
}
async loadStatistics() {
try {
const stats = await this.orm.call("sale.order", "get_order_statistics");
this.state.statistics = stats;
} catch (error) {
console.error("Failed to load statistics:", error);
}
}
The interesting part is processSelectedOrders: it validates the selection, confirms with the user, then coordinates a batch ORM call, a success notification, a data refresh, and finally opens a report — all five services working together around one user action:
async processSelectedOrders() {
if (this.state.selectedOrders.size === 0) {
this.notification.add("Please select orders to process.", {
type: "warning"
});
return;
}
const orderIds = Array.from(this.state.selectedOrders);
// Confirmation dialog
this.dialog.add(ConfirmationDialog, {
title: "Process Orders",
body: `Process ${orderIds.length} selected orders?`,
confirm: async () => {
try {
this.state.processing = true;
// Progress notification
this.notification.add(`Processing ${orderIds.length} orders...`, {
type: "info"
});
// Batch process orders
await this.orm.call("sale.order", "action_confirm", orderIds);
// Success feedback
this.notification.add(`Successfully processed ${orderIds.length} orders!`, {
type: "success"
});
// Clear selection and refresh
this.state.selectedOrders.clear();
await this.loadOrders();
await this.loadStatistics();
// Open processing report
this.action.doAction({
type: "ir.actions.report",
report_name: "sale.action_report_saleorder",
context: { active_ids: orderIds }
});
} catch (error) {
console.error("Order processing failed:", error);
this.notification.add(`Processing failed: ${error.message}`, {
type: "danger",
sticky: true
});
} finally {
this.state.processing = false;
}
}
});
}
}
Summary
Odoo's built-in services provide a comprehensive toolkit for creating professional, integrated applications:
notification: User feedback and status updatesaction: Navigation and Odoo integrationdialog: User interactions and confirmationsuser,company,router: System information and navigation
By mastering these services, your components will: - Feel native to the Odoo experience - Provide consistent user interactions - Handle errors gracefully - Integrate seamlessly with Odoo's workflows
The key to success is understanding when and how to use each service, combining them effectively for complex workflows, and always prioritizing the user experience with clear feedback and intuitive interactions.
In the next chapter, we'll explore advanced component patterns and architectural considerations for building large-scale OWL applications.
TL;DR: Compose services deliberately (one notification per step of a multi-step workflow), debounce anything that fires on every keystroke with @web/core/utils/timing, and wrap unreliable calls in a retry-with-backoff strategy instead of failing silently.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch13_2_ex1. Installation instructions are in the repository README.
Exercises
- Composed workflow: Write a method that creates a partner, then immediately creates a related contact for it, showing one progress notification per step (similar to Pattern 3). Make sure a failure at either step shows which step failed.
- Debounce it yourself: Implement a 300ms debounce over a live search input backed by
orm.searchRead, without using Odoo's built-indebouncehelper — then swap inimport { debounce } from "@web/core/utils/timing"and confirm the behavior is the same. - Retry with backoff: Take
performResilientOperationand adapt it to retry anorm.callup to 3 times, then write a quick test (real or on paper) describing what notification the user should see after each failed attempt.
What's Next?
You've now covered the full service layer, from first notification to resilient multi-step workflows. Chapter 14 moves to composition — slots, scoped slots, and t-portal — the tools for building reusable, flexible components instead of one-off ones.