Why this chapter? Every non-trivial Odoo addon eventually needs to read or write real records — a component that only manages local state is a toy. This chapter is where your components start talking to the actual database. What is Odoo trying to solve with this? Odoo needs a consistent, safe way for frontend code to reach the ORM and custom controllers without every developer hand-rolling their own AJAX calls, error handling, and security checks. Real-world application: Every dashboard widget, custom kanban action, and portal form I've built for a client eventually comes down to
orm.searchRead/create/writecalls exactly like the ones in this chapter.
In the previous chapters, we've built components that can manage state, respond to events, and handle complex lifecycle scenarios. But a modern web application isn't complete without the ability to communicate with the server. Your beautiful OWL components need to fetch real data, save user changes, and integrate seamlessly with Odoo's backend.
This is where the service system comes in. Odoo's service architecture provides a clean, consistent way for your frontend components to communicate with the Python backend, display notifications, trigger actions, and much more.
The gateway to this powerful system is the useService hook—your components' bridge to the entire Odoo ecosystem.
Understanding Odoo's Service Architecture
Services in Odoo are singleton objects that provide specific functionality across your entire application. They're designed to be:
- Centralized: One instance per service type
- Consistent: Same API everywhere in the app
- Integrated: Seamlessly connected to Odoo's backend
- Testable: Easy to mock and test
Think of services as your application's "utilities"—specialized tools that handle specific concerns so your components can focus on their primary job: rendering UI and handling user interactions.
The useService Hook
The useService hook is remarkably simple:
import { useService } from "@web/core/utils/hooks";
// Inside your component's setup()
const serviceName = useService("service_name");
That's it! You get a fully configured service instance ready to use. Let's explore the most important services you'll use daily.
The orm Service: Your Database Gateway
The orm service is your primary tool for database operations. It provides a clean, Promise-based API that mirrors Odoo's Python ORM methods, making server communication feel natural and intuitive.
Basic CRUD Operations
Let's build a comprehensive product management component that demonstrates all the core ORM operations:
JavaScript (product_manager.js):
import { Component, useEffect, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ProductManager extends Component {
static template = "my_module.ProductManager";
setup() {
this.state = useState({
products: [],
categories: [],
loading: true,
error: null,
selectedProduct: null,
formData: {
name: "",
list_price: 0,
categ_id: null,
description: ""
}
});
// Get the ORM service
this.orm = useService("orm");
// Load initial data when component mounts
useEffect(
() => {
this.loadInitialData();
},
() => []
);
}
async loadInitialData() {
try {
console.log("ProductManager: Loading initial data");
this.state.loading = true;
this.state.error = null;
// Load products and categories in parallel for better performance
const [products, categories] = await Promise.all([
this.loadProducts(),
this.loadCategories()
]);
this.state.products = products;
this.state.categories = categories;
console.log(`ProductManager: Loaded ${products.length} products and ${categories.length} categories`);
} catch (error) {
console.error("ProductManager: Failed to load initial data:", error);
this.state.error = "Failed to load data. Please refresh the page.";
} finally {
this.state.loading = false;
}
}
async loadProducts() {
// searchRead: Search for records and read their fields in one call
return await this.orm.searchRead(
"product.product", // Model name
[["sale_ok", "=", true]], // Domain (search filters)
["id", "name", "list_price", "categ_id", "qty_available"], // Fields to read
{
order: "name asc", // Sort order
limit: 100 // Maximum records
}
);
}
async loadCategories() {
return await this.orm.searchRead(
"product.category",
[], // Empty domain = all records
["id", "name"],
{ order: "name asc" }
);
}
async createProduct() {
if (!this.state.formData.name.trim()) {
this.state.error = "Product name is required";
return;
}
try {
console.log("ProductManager: Creating new product", this.state.formData);
// create: Create new records
const newProductIds = await this.orm.create(
"product.product",
[{
name: this.state.formData.name,
list_price: this.state.formData.list_price,
categ_id: this.state.formData.categ_id,
sale_ok: true,
purchase_ok: true
}]
);
console.log(`ProductManager: Created product with ID ${newProductIds[0]}`);
// Reload products to show the new one
await this.refreshProducts();
// Reset form
this.resetForm();
this.state.error = null;
} catch (error) {
console.error("ProductManager: Failed to create product:", error);
this.state.error = `Failed to create product: ${error.message}`;
}
}
async updateProduct(productId, updates) {
try {
console.log(`ProductManager: Updating product ${productId}`, updates);
// write: Update existing records
await this.orm.write(
"product.product",
[productId], // List of record IDs to update
updates // Dictionary of field updates
);
console.log(`ProductManager: Successfully updated product ${productId}`);
// Refresh the product list
await this.refreshProducts();
} catch (error) {
console.error(`ProductManager: Failed to update product ${productId}:`, error);
this.state.error = `Failed to update product: ${error.message}`;
}
}
async deleteProduct(productId) {
try {
console.log(`ProductManager: Deleting product ${productId}`);
// unlink: Delete records
await this.orm.unlink("product.product", [productId]);
console.log(`ProductManager: Successfully deleted product ${productId}`);
// Remove from local state immediately for better UX
this.state.products = this.state.products.filter(p => p.id !== productId);
// Clear selection if deleted product was selected
if (this.state.selectedProduct?.id === productId) {
this.state.selectedProduct = null;
}
} catch (error) {
console.error(`ProductManager: Failed to delete product ${productId}:`, error);
this.state.error = `Failed to delete product: ${error.message}`;
}
}
async refreshProducts() {
try {
const products = await this.loadProducts();
this.state.products = products;
console.log("ProductManager: Products refreshed successfully");
} catch (error) {
console.error("ProductManager: Failed to refresh products:", error);
}
}
// Helper methods for form handling
selectProduct(product) {
this.state.selectedProduct = product;
this.state.formData = {
name: product.name,
list_price: product.list_price,
categ_id: product.categ_id[0],
description: ""
};
}
resetForm() {
this.state.formData = {
name: "",
list_price: 0,
categ_id: null,
description: ""
};
this.state.selectedProduct = null;
}
onFormChange(field, value) {
this.state.formData[field] = value;
}
// Quick actions for product management
async quickUpdatePrice(productId, newPrice) {
await this.updateProduct(productId, { list_price: newPrice });
}
async toggleProductAvailability(productId, currentStatus) {
await this.updateProduct(productId, { sale_ok: !currentStatus });
}
// Computed properties for template
get formattedProducts() {
return this.state.products.map(product => ({
...product,
formattedPrice: `$${product.list_price.toFixed(2)}`,
categoryName: product.categ_id ? product.categ_id[1] : "No Category",
stockStatus: product.qty_available > 0 ? "In Stock" : "Out of Stock"
}));
}
get isFormValid() {
return this.state.formData.name.trim().length > 0;
}
}
Template (product_manager.xml):
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.ProductManager" owl="1">
<div class="product-manager">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Product Manager</h2>
<button
class="btn btn-outline-secondary btn-sm"
t-on-click="refreshProducts"
t-att-disabled="state.loading">
<i class="fa fa-refresh"></i> Refresh
</button>
</div>
<!-- Error Display -->
<t t-if="state.error">
<div class="alert alert-danger alert-dismissible">
<t t-esc="state.error"/>
<button
type="button"
class="btn-close"
t-on-click="() => this.state.error = null">
</button>
</div>
</t>
<!-- Loading State -->
<t t-if="state.loading">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading products...</span>
</div>
<p class="mt-2 text-muted">Loading products and categories...</p>
</div>
</t>
<!-- Main Content -->
<t t-else="">
<div class="row">
<!-- Product Form -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<t t-if="state.selectedProduct">Edit Product</t>
<t t-else="">New Product</t>
</h5>
</div>
<div class="card-body">
<form t-on-submit.prevent="createProduct">
<!-- Product Name -->
<div class="mb-3">
<label class="form-label">Product Name *</label>
<input
type="text"
class="form-control"
t-model="state.formData.name"
placeholder="Enter product name"
required
/>
</div>
<!-- Price -->
<div class="mb-3">
<label class="form-label">Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input
type="number"
class="form-control"
t-model="state.formData.list_price"
min="0"
step="0.01"
/>
</div>
</div>
<!-- Category -->
<div class="mb-3">
<label class="form-label">Category</label>
<select
class="form-select"
t-model="state.formData.categ_id">
<option value="">Select Category</option>
<t t-foreach="state.categories" t-as="category" t-key="category.id">
<option t-att-value="category.id" t-esc="category.name"/>
</t>
</select>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary flex-fill"
t-att-disabled="!isFormValid">
<t t-if="state.selectedProduct">Update Product</t>
<t t-else="">Create Product</t>
</button>
<button
type="button"
class="btn btn-outline-secondary"
t-on-click="resetForm">
Clear
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Product List -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
Products (<t t-esc="state.products.length"/>)
</h5>
</div>
<div class="card-body p-0">
<t t-if="state.products.length === 0">
<div class="text-center py-4 text-muted">
<i class="fa fa-box-open fa-2x mb-2"></i>
<p>No products found. Create your first product!</p>
</div>
</t>
<t t-else="">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="formattedProducts" t-as="product" t-key="product.id">
<tr t-att-class="state.selectedProduct?.id === product.id ? 'table-active' : ''">
<td>
<strong t-esc="product.name"/>
</td>
<td>
<small class="text-muted" t-esc="product.categoryName"/>
</td>
<td>
<span t-esc="product.formattedPrice"/>
</td>
<td>
<span
class="badge"
t-att-class="product.qty_available > 0 ? 'bg-success' : 'bg-warning'">
<t t-esc="product.stockStatus"/>
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button
class="btn btn-outline-primary"
t-on-click="() => this.selectProduct(product)"
title="Edit">
<i class="fa fa-edit"></i>
</button>
<button
class="btn btn-outline-danger"
t-on-click="() => this.deleteProduct(product.id)"
title="Delete">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>
This example demonstrates all the key ORM operations:
searchRead: Search and read records in one operationcreate: Create new recordswrite: Update existing recordsunlink: Delete records
Advanced ORM Operations
The orm service provides more advanced methods for complex scenarios:
Using read for Specific Records:
// Read specific records by ID
const products = await this.orm.read(
"product.product",
[1, 2, 3], // Specific record IDs
["name", "list_price", "description"] // Fields to read
);
Using search and searchCount:
// Get only record IDs matching criteria
const productIds = await this.orm.search(
"product.product",
[["sale_ok", "=", true]],
{ limit: 5, offset: 10 }
);
// Count records without fetching them
const totalProducts = await this.orm.searchCount(
"product.product",
[["sale_ok", "=", true]]
);
Calling Custom Model Methods:
// Call any method on your Python model
const result = await this.orm.call(
"product.product", // Model name
"my_custom_method", // Method name
[recordId], // Args (usually record IDs)
{ // Kwargs
param1: "value1",
param2: "value2"
}
);
The rpc Service: Direct Controller Communication
While the orm service is perfect for model operations, sometimes you need to call controller routes directly. The rpc service (short for Remote Procedure Call — invoking a function that actually runs on the server, as if it were local) provides this capability:
JavaScript Example:
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class CustomReportGenerator extends Component {
static template = "my_module.CustomReportGenerator";
setup() {
this.state = useState({
reportData: null,
loading: false,
filters: {
start_date: "",
end_date: "",
partner_ids: []
}
});
this.rpc = useService("rpc");
}
async generateReport() {
try {
this.state.loading = true;
console.log("CustomReportGenerator: Generating custom report");
// Call your custom controller route
const reportData = await this.rpc("/my_module/generate_report", {
filters: this.state.filters,
format: "json",
include_details: true
});
this.state.reportData = reportData;
console.log("CustomReportGenerator: Report generated successfully");
} catch (error) {
console.error("CustomReportGenerator: Report generation failed:", error);
// Handle error appropriately
} finally {
this.state.loading = false;
}
}
async downloadReportPDF() {
try {
console.log("CustomReportGenerator: Downloading PDF report");
// This might return a blob or URL for download
const pdfData = await this.rpc("/my_module/generate_report", {
filters: this.state.filters,
format: "pdf"
});
// Handle PDF download logic here
} catch (error) {
console.error("CustomReportGenerator: PDF download failed:", error);
}
}
}
Error Handling and User Feedback
Professional applications need robust error handling. Here's how to handle common scenarios:
Comprehensive Error Handling Pattern:
async performDatabaseOperation() {
try {
this.state.loading = true;
this.state.error = null;
const result = await this.orm.searchRead(
"some.model",
this.buildDomain(),
this.getRequiredFields()
);
this.state.data = result;
} catch (error) {
console.error("Database operation failed:", error);
// Different error types need different handling
if (error.code === 403) {
this.state.error = "You don't have permission to access this data.";
} else if (error.code === 404) {
this.state.error = "The requested data was not found.";
} else if (error.message.includes("network")) {
this.state.error = "Network error. Please check your connection.";
} else {
this.state.error = `Operation failed: ${error.message}`;
}
} finally {
this.state.loading = false;
}
}
Optimistic Updates Pattern:
async quickUpdate(recordId, field, value) {
// Store original value for rollback
const originalData = [...this.state.records];
try {
// Update UI immediately (optimistic)
const record = this.state.records.find(r => r.id === recordId);
if (record) {
record[field] = value;
}
// Then update server
await this.orm.write("model.name", [recordId], { [field]: value });
console.log("Quick update successful");
} catch (error) {
// Rollback UI on error
this.state.records = originalData;
console.error("Quick update failed, rolled back:", error);
// Show user-friendly error message
this.showError(`Failed to update ${field}. Please try again.`);
}
}
Service Integration Patterns
Pattern 1: Service Composition
setup() {
// Multiple services working together
this.orm = useService("orm");
this.rpc = useService("rpc");
this.notification = useService("notification");
this.action = useService("action");
// Composed operation using multiple services
this.performComplexOperation = async () => {
try {
// 1. Save data via ORM
const [recordId] = await this.orm.create("model.name", [this.state.formData]);
// 2. Trigger server-side processing via RPC
await this.rpc("/custom/process_record", { record_id: recordId });
// 3. Show success notification
this.notification.add("Record created and processed successfully!", {
type: "success"
});
// 4. Navigate to the new record
this.action.doAction({
type: "ir.actions.act_window",
res_model: "model.name",
res_id: recordId,
views: [[false, "form"]],
target: "current"
});
} catch (error) {
this.notification.add("Operation failed. Please try again.", {
type: "danger"
});
}
};
}
Pattern 2: Service Abstraction
setup() {
this.orm = useService("orm");
// Create abstraction layer for your specific domain
this.customerService = {
async getCustomers(filters = {}) {
return await this.orm.searchRead(
"res.partner",
[["is_company", "=", true], ...this.buildDomain(filters)],
["name", "email", "phone", "city"],
{ order: "name asc" }
);
},
async createCustomer(customerData) {
return await this.orm.create("res.partner", [customerData]);
},
async updateCustomer(customerId, updates) {
return await this.orm.write("res.partner", [customerId], updates);
}
};
}
Performance Considerations
Batch Operations
// BAD: Multiple individual calls
for (const productId of productIds) {
await this.orm.write("product.product", [productId], { active: false });
}
// GOOD: Single batch call
await this.orm.write("product.product", productIds, { active: false });
Efficient Field Selection
// BAD: Loading unnecessary data
const products = await this.orm.searchRead("product.product", [], []);
// GOOD: Only load what you need
const products = await this.orm.searchRead(
"product.product",
[],
["id", "name", "list_price"] // Only the fields you actually use
);
Smart Caching Pattern
setup() {
this.orm = useService("orm");
this.cache = new Map();
this.getCachedData = async (cacheKey, fetcher) => {
if (this.cache.has(cacheKey)) {
console.log(`Using cached data for ${cacheKey}`);
return this.cache.get(cacheKey);
}
const data = await fetcher();
this.cache.set(cacheKey, data);
return data;
};
this.getCategories = () => {
return this.getCachedData('categories', () =>
this.orm.searchRead("product.category", [], ["id", "name"])
);
};
}
Common Pitfalls and Solutions
Pitfall 1: Assuming searchRead Returns a Wrapper Object
// Wrong - some ORMs wrap results in an object; Odoo's doesn't
const { records } = await this.orm.searchRead("res.partner", [], ["name"]);
// Correct - searchRead resolves directly to an array of records
const records = await this.orm.searchRead("res.partner", [], ["name"]);
Pitfall 2: Forgetting await
// Wrong - every orm method returns a Promise; without await you store the Promise itself
loadProducts() {
this.state.products = this.orm.searchRead("product.product", [], ["name"]);
}
// Correct
async loadProducts() {
this.state.products = await this.orm.searchRead("product.product", [], ["name"]);
}
Pitfall 3: Not Handling Server Errors
// Wrong - an unhandled rejection can crash the whole action
async saveRecord() {
await this.orm.create("res.partner", [this.state.formData]);
}
// Correct - wrap in try/catch and give the user feedback
async saveRecord() {
try {
await this.orm.create("res.partner", [this.state.formData]);
} catch (error) {
this.state.error = "Could not save the record. Please try again.";
}
}
Pitfall 4: Forgetting that create Returns an Array of IDs
// Wrong - assuming create resolves to a single id
const id = await this.orm.create("res.partner", [{ name: "Test" }]);
// Correct - create always resolves to an array, even for a single record
const [id] = await this.orm.create("res.partner", [{ name: "Test" }]);
Testing Service Integration
Because components receive services through useService, tests can swap in mocks. The exact helpers depend on your Odoo version (we'll cover Odoo's real test framework in Chapter 16 Part 1), but the idea always looks like this:
// Pseudo-code: the shape of a service-mocking test
describe("ProductManager", () => {
test("should load products on mount", async () => {
const mockOrm = {
searchRead: jest.fn().mockResolvedValue([
{ id: 1, name: "Test Product", list_price: 10.0 }
])
};
const component = await makeTestComponent(ProductManager, {
services: {
orm: mockOrm
}
});
await nextTick(); // Wait for effects to complete
expect(mockOrm.searchRead).toHaveBeenCalledWith(
"product.product",
[["sale_ok", "=", true]],
expect.any(Array),
expect.any(Object)
);
expect(component.state.products).toHaveLength(1);
});
});
Summary
The useService hook and Odoo's service system provide:
ormservice: Complete CRUD operations for Odoo modelsrpcservice: Direct communication with custom controllers- Consistent API: Same patterns across your entire application
- Error handling: Built-in error management and recovery
- Performance: Optimized communication with the backend
Key principles for effective service usage:
- Use the right service for each task (ORM for models, RPC for controllers)
- Handle errors gracefully with user-friendly messages
- Batch operations when possible for better performance
- Cache strategically to reduce server requests
- Test with mocks to ensure reliability
In the next chapter, we'll explore more of Odoo's built-in services like notifications and actions, which will help you create truly integrated user experiences.
TL;DR: Use useService("orm") for CRUD against Odoo models (searchRead, create, write, unlink) and useService("rpc") for custom controllers — always await them, always wrap calls in try/catch, and remember create returns an array of ids, not a bare id.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch12_ex1. Installation instructions are in the repository README.
Exercises
- Basic CRUD: Build a tiny component that lists
res.partnerrecords (searchReadwith justnameandemail), and add a button that creates a new partner named "Test Contact" usingorm.create. Remembercreatereturns an array of ids. - Handle the error: Wrap a
orm.writecall in atry/catchand show a Bootstrap alert with a friendly message if it fails. Test it by passing an invalid model name and see what happens without the try/catch first. - Count before you fetch: Use
orm.searchCountto show "X contacts found" before actually loading the full list withsearchRead, so the user sees a number immediately while the data streams in.
What's Next?
With data flowing between your components and the server, Chapter 13 turns to the rest of Odoo's built-in services — notifications, dialogs, actions, and permissions — the pieces that make a component feel like a native part of Odoo instead of a bolted-on widget.