Writing custom Odoo modules that survive major software updates, maintain fast execution times, and remain readable requires software engineering discipline. As Odoo releases major versions annually (Odoo 17, 18, and 19), unrefined custom code quickly turns into technical debt — the same debt I cover from the process side in Odoo customization best practices.
Following established architectural patterns ensures your custom apps integrate smoothly with native Odoo modules while simplifying future database migrations.
Key Development Principles at a Glance
Core Pillars of Custom Odoo Development
| 1. Extension Inheritance | 2. Logic in Models | 3. Granular Security | 4. Automated Testing | 5. Upgrade Protection |
|---|
1. Use Extension Inheritance, Not Copying
Always extend core models using _inherit rather than redefining existing models or hacking core source code. Extension inheritance ensures your custom logic remains compatible when base Odoo code receives security patches or version updates.
# Good Practice: Extension Inheritance
from odoo import models, fields, api
class SaleOrder(models.Model):
_inherit = 'sale.order'
custom_commission_rate = fields.Float(
string="Commission Rate (%)",
digits=(16, 2),
help="Custom sales representative commission rate."
)
-
View Extensions: Always use XPath expressions (<xpath expr="..." position="...">) to modify existing forms, trees, or kanban views instead of overriding entire view templates.
-
Keep Dependencies Explicit: Declare all module dependencies accurately inside your manifest.py file under the 'depends' key to avoid load-order errors.
2. Keep Business Logic Inside Models
Resist the temptation to embed heavy business logic inside HTTP controllers, webhooks, or UI wizards. Models are the single source of truth — keeping logic inside model methods makes features reusable across automated server actions, XML-RPC APIs, and external integrations.
# Good Practice: Clean, Reusable Compute Logic
@api.depends('order_line.price_total')
def _compute_custom_totals(self):
for order in self:
# Use filtered() or mapped() over manual loops where clean
discounted_lines = order.order_line.filtered(lambda l: l.discount > 0)
order.total_discounted_amount = sum(discounted_lines.mapped('price_subtotal'))
Performance Rules for ORM Logic
-
Avoid Database Operations in Loops: Never execute direct SQL queries or trigger write calls (write(), create()) inside loops.
-
Batch Operations: Perform record operations across recordsets (e.g., records.write({'state': 'done'})) rather than iterating through records individually.
-
Decorators: Use @api.depends() correctly to compute stored fields, and avoid non-stored compute fields in large list views to prevent SQL query bloat.
3. Implement Security Rules Early
Security should never be retrofitted at the end of a project. Define access control rules (ir.model.access.csv) and record rules (ir.rule) right from the initial module structure.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_custom_deal_user,custom.deal.user,model_custom_deal,base.group_user,1,1,1,0
access_custom_deal_manager,custom.deal.manager,model_custom_deal,sales_team.group_sale_manager,1,1,1,1
-
Avoid Overusing base.group_user: Define explicit custom access groups inside security/security_groups.xml.
-
Sudo Guardrails: Avoid calling .sudo() across entire processes. Use .sudo() sparingly and restrict its scope strictly to security bypasses required by lower-privilege operations.
4. Write Automated Unit & Integration Tests
Odoo features a fast, built-in test runner built on Python's unittest framework. Writing unit tests for business logic, custom calculations, and constraint methods prevents quiet regressions during version updates.
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError
class TestCustomSales(TransactionCase):
def setUp(self):
super(TestCustomSales, self).setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Partner'})
def test_commission_calculation(self):
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'custom_commission_rate': 10.0,
})
self.assertEqual(order.custom_commission_rate, 10.0)
Run tests locally during development using the following command:
odoo-bin -c odoo.conf -d test_db -i custom_module_name --test-enable --stop-after-init
5. Build for Safe Upgrades
To ensure your custom modules move seamlessly through version migrations (e.g., from Odoo 17/18 to 19):
-
Avoid Overriding _auto_init or init: Use standard field definitions and ORM tools instead of writing custom raw SQL DDL unless required for complex database views.
-
Use Hooks for Data Transformations: Execute data migrations using post_init_hook or dedicated migration scripts inside the migrations/ directory.
-
Keep XML IDs Consistent: Never alter primary xml_id identifiers once deployed to production, as this corrupts database references during updates.
Custom Module Quality Checklist
| Area | Best Practice Check | Risk Avoided |
|---|---|---|
| Architecture | Inherit existing models using _inherit | Preserves standard core fixes and features |
| Performance | Batch record updates (write, create) | Eliminates slow UI responses and memory spikes |
| Security | Explicit ir.model.access.csv and Record Rules | Prevents unauthorized data access across users |
| Upgrades | Use XPath selectors in views, avoid hardcoded IDs | Prevents broken interfaces during major upgrades |
Need Support with Custom Odoo Development?
Building enterprise-grade Odoo systems takes backend Python expertise combined with a real understanding of Odoo's core workflows — the patterns above are the difference between a module that survives its first major upgrade and one that becomes next year's rewrite. If you're planning custom development, refactoring legacy modules, or want an experienced second opinion on your architecture before you build, get in touch or see how this fits into full Odoo implementation work.
By Shahid Malik