
A custom Odoo module can work perfectly today and still become expensive six months later.
The real test is often not:
"Does this module work?"
It is:
"Will we still understand and maintain this module when the next Odoo version arrives?"
That is where good Odoo custom module development starts to look different from simply making a feature work.
Odoo is designed to be extended through modules, inheritance, views, ORM methods, and frontend components. But every unnecessary dependency on internal implementation details increases the amount of work required during an upgrade.
Here are the practices that make custom modules much easier to carry forward.
1. Never Solve Customization by Editing Odoo Core
The fastest-looking solution is often the most expensive later.
Imagine needing to change sales order confirmation.
Editing the original Odoo method might work immediately.
But now your implementation depends on maintaining a modified copy of standard Odoo code.
When the standard method changes in a future release, you have to manually determine:
What changed in Odoo?
+
What did we change?
+
Which changes should survive?
Instead, extend the existing model.
This inheritance-first approach is also central to upgrade-safe Odoo customization, where extensions are kept separate from core functionality so future version changes are easier to manage.
from odoo import models
class SaleOrder(models.Model):
_inherit = "sale.order"
def action_confirm(self):
result = super().action_confirm()
self._run_custom_confirmation_logic()
return result
def _run_custom_confirmation_logic(self):
# Custom business logic
pass
The important part is not the exact example.
It is the structure:
Standard Odoo
↓
Inheritance
↓
Small custom extension
instead of:
Copied Odoo code
↓
Modified copy
↓
Future merge problem
Odoo explicitly provides model and view inheritance as mechanisms for extending existing functionality.
2. Keep Overrides Small
Even when inheritance is used correctly, an override can still become difficult to upgrade.
Consider a 250-line override of:
action_confirm()
If Odoo changes that method in the next version, understanding the difference becomes painful.
A better pattern is to keep the override thin:
def action_confirm(self):
result = super().action_confirm()
self._create_external_reference()
return result
Then place the actual custom behavior inside methods your module owns.
def _create_external_reference(self):
...
Now an upgrade review asks:
Does
action_confirm()still exist and is this extension point still appropriate?
instead of:
Which parts of these 250 lines came from Odoo three versions ago?
That dramatically reduces upgrade review time.
3. Declare Dependencies Explicitly
The module manifest is more important than it looks.
A typical module might contain:
{
"name": "Custom Sales Workflow",
"version": "1.0.0",
"depends": [
"sale",
"stock",
],
"data": [
"security/ir.model.access.csv",
"views/sale_order_views.xml",
],
}
Odoo uses module dependencies to determine which modules must be installed and updated before another module. Its current module documentation specifically describes the manifest as the place where module metadata and dependencies are declared.
Avoid depending on modules simply because:
"They are installed anyway."
If your code imports functionality from another module, make that relationship visible.
Explicit dependencies make future failures easier to diagnose.
4. Avoid Deep, Fragile XPath Selectors
Views are one of the most common places where upgrades expose fragile customization.
This kind of selector should make you nervous:
<xpath expr="//form/sheet/group/group[2]/div[3]" position="inside">
Why?
Because it depends heavily on the exact structure of the parent view.
If Odoo rearranges those containers, the XPath may stop matching.
Whenever possible, target something more meaningful.
<field name="client_order_ref" position="after">
<field name="integration_reference"/>
</field>
Or use a carefully targeted XPath tied to a stable element.
The goal is to depend on:
Business meaning
rather than:
DOM position
Odoo's view inheritance mechanism is specifically designed to apply extension views over parent views using inherited records and targeted selectors.
5. Don't Copy Entire Standard Views
Another common shortcut is:
- Copy the original form view.
- Modify it.
- Replace the standard version.
That gives you control.
It also gives you responsibility for maintaining everything that Odoo changes in that view.
Suppose the next version adds:
New buttons
New fields
New widgets
Improved visibility conditions
Security changes
Your copied view does not automatically inherit those improvements.
A smaller inherited view usually creates a much cleaner upgrade path.
Think:
Standard view
+ 10 lines of customization
instead of:
600-line copied standard view
+ customization
Smaller customization surfaces generally mean smaller upgrade surfaces.
6. Use the ORM Unless You Have a Strong Reason Not To
Raw SQL sometimes has valid uses.
But it should not be the default for ordinary business logic.
Compare:
self.env.cr.execute(
"""
UPDATE sale_order
SET integration_reference = %s
WHERE id = %s
""",
[reference, self.id],
)
with:
self.integration_reference = reference
The ORM communicates intent much more clearly and stays inside Odoo's model layer.
It also avoids bypassing behavior that may exist around fields, models, access control, caching, and related framework functionality.
For upgrade-safe Odoo custom module development, keeping business logic at the framework level wherever practical makes future changes easier to reason about.
Raw SQL should generally be isolated to cases where you actually need it—for example, certain migration or performance-sensitive operations—and then covered carefully by tests.
7. Stop Hardcoding Database IDs
This is fragile:
if user.id == 7:
...
So is:
group_id = 42
Those IDs belong to a particular database.
A restored database, fresh environment, test database, or upgraded system may not have the same numbers.
Prefer XML/external IDs when referring to known records:
review_group = self.env.ref(
"my_module.group_order_reviewer"
)
Now the code references the logical record instead of whatever database ID it happened to receive.
This matters especially when the same module needs to run across:
Developer DB
Testing DB
Staging DB
Production DB
Upgraded DB
8. Keep Business Logic Out of Views and Controllers
A module becomes much easier to maintain when the responsibility of each layer is clear.
A useful structure is:
Model
→ Business logic
Controller
→ HTTP/API boundary
View
→ Presentation
Security
→ Permissions
Data
→ Configuration/reference records
If an important calculation exists only inside a controller, another workflow may not be able to reuse it.
Instead of:
class CustomController(...):
def submit_order(self):
# 80 lines of business logic
...
prefer:
class SaleOrder(models.Model):
def run_custom_process(self):
...
and:
class CustomController(...):
def submit_order(self):
order.run_custom_process()
Now tests, cron jobs, UI actions, imports, and API endpoints can share the same business logic.
9. Treat Frontend Customization as an Upgrade Boundary Too
Backend Python is not the only source of upgrade problems.
Custom JavaScript can become expensive when it relies heavily on internal component structure.
Modern Odoo's frontend uses Owl and framework-level components and services.
Before patching frontend behavior, ask:
Can this be done through an existing extension point?
Can a registry be extended?
Can a component be inherited?
Can the change remain isolated?
The more deeply custom JavaScript reaches into private implementation details, the more likely it is to require attention when the web client evolves.
A useful rule is:
Patch the smallest surface necessary.
10. Give Every Important Customization a Test
An upgrade should not depend entirely on someone clicking through every screen and saying:
"Looks okay."
Odoo provides module testing support based on Python's testing infrastructure, and its documentation recommends defining tests alongside the module that introduces the functionality.
Suppose your module adds a rule:
Orders above a certain amount require approval.
Test the business rule directly.
Conceptually:
def test_large_order_requires_approval(self):
order = self.env["sale.order"].create({
# test data
})
self.assertTrue(order.requires_approval)
Then test the opposite condition too.
def test_small_order_does_not_require_approval(self):
...
Tests turn an Odoo upgrade from:
Install new version
↓
Click around
↓
Hope nothing broke
into:
Install new version
↓
Update custom modules
↓
Run tests
↓
Investigate failures
That is a much stronger upgrade workflow.
11. Test Behavior, Not Odoo's Implementation
Tests themselves can become upgrade liabilities.
A brittle test may assert internal implementation details:
Method X must call method Y exactly twice.
But the actual business requirement may simply be:
Approved order must produce the expected result.
Prefer tests around business outcomes.
For example:
Given:
A confirmed order requiring manager approval
When:
An unauthorized user attempts approval
Then:
Approval is rejected
This test can remain useful even if the internal implementation changes.
That is exactly what you want during a version upgrade.
12. Design Data Changes as Migrations
Code is only half the module.
Production modules also accumulate data.
Imagine version 1 stores:
status = "approved"
but a later design introduces:
approval_state = "manager_approved"
Changing the Python field definition does not automatically answer:
What should happen to thousands of existing database records?
That belongs in an upgrade strategy.
Odoo supports module upgrade scripts through a migrate() function, and its current upgrade utilities are specifically intended to help developers adapt stored data when module structures evolve.
Conceptually:
def migrate(cr, version):
# Transform existing data required by the new module version.
...
Think about migrations whenever you:
Rename fields
Replace models
Change stored values
Move data
Change relationships
Remove old structures
A module is not upgrade-safe if only fresh installations work.
13. Avoid Building One Giant "custom" Module
It starts innocently.
custom_company
Then it contains:
Sales customization
Inventory customization
Accounting customization
CRM customization
Website customization
POS customization
Integration code
Reports
Eventually, every change depends on everything else.
Instead, use boundaries that reflect functionality.
For example:
company_sale_extension
company_inventory_extension
company_pos_extension
company_account_extension
company_external_connector
That does not mean turning every tiny feature into a separate addon.
It means keeping unrelated responsibilities from becoming one upgrade problem.
When Odoo changes POS behavior, you should ideally be able to inspect the POS-related customization without reviewing an unrelated accounting report.
14. Keep the Dependency Graph Small
Imagine:
Module A
↓
Module B
↓
Module C
↓
Module D
↓
Module E
Changing Module E may now affect everything above it.
Before introducing a dependency, ask:
Does this module genuinely require the other module?
Reducing unnecessary dependencies makes upgrades easier to isolate and test.
15. Read the Upgrade as a Code Review, Not Just a Deployment
A major version upgrade is a useful opportunity to inspect old assumptions.
For each custom module, review:
Models inherited
Methods overridden
Views inherited
XPath selectors
JavaScript patches
Controllers
Cron jobs
Security rules
External APIs
Dependencies
Deprecated behavior
Migration scripts
Tests
Then compare those extension points against the target Odoo version.
Odoo's current upgrade guidance is explicit: if a database contains custom modules, compatible versions of those modules are needed for the target version.
So the best time to make an upgrade inexpensive is not when the upgrade starts.
It is when the module is originally written.
A major Odoo version upgrade can expose changes in models, fields, constraints, views, and custom code, which is why reviewing extension points before migration is so important.
A Simple Upgrade-Friendly Module Structure
A clean addon might look like:
custom_sale_approval/
│
├── __init__.py
├── __manifest__.py
│
├── models/
│ ├── __init__.py
│ └── sale_order.py
│
├── security/
│ ├── security.xml
│ └── ir.model.access.csv
│
├── views/
│ └── sale_order_views.xml
│
├── data/
│ └── approval_data.xml
│
└── tests/
├── __init__.py
└── test_sale_approval.py
The exact structure can grow with the feature.
The important property is that another developer can quickly answer:
Where is the model logic?
Where are the views?
Where is security defined?
Where are the tests?
What does this module depend on?
Odoo's own coding guidelines emphasize consistent module structure and maintainable code because these practices make development, debugging, and maintenance easier.
The Upgrade-Survival Checklist
Before calling an Odoo custom module development task finished, ask:
[ ] Did we modify any Odoo core files?
[ ] Are overrides small?
[ ] Are we calling super() where appropriate?
[ ] Are dependencies explicit?
[ ] Are inherited views minimal?
[ ] Are XPath selectors reasonably stable?
[ ] Did we avoid hardcoded database IDs?
[ ] Is business logic located in reusable model methods?
[ ] Are custom frontend patches isolated?
[ ] Are important workflows tested?
[ ] Do tests verify business outcomes?
[ ] Will existing production data survive future schema changes?
[ ] Is the module responsible for one coherent area?
[ ] Could another developer understand why this customization exists?
If several answers are uncomfortable, the upgrade will probably be uncomfortable too.
Final Takeaway
Upgrade-safe Odoo custom module development is not about predicting exactly what Odoo will change in its next release.
That is impossible.
It is about reducing how much of your code depends on implementation details that you do not control.
The pattern is fairly consistent:
Extend instead of copy
Use stable framework mechanisms
Keep overrides small
Keep dependencies explicit
Use the ORM appropriately
Isolate frontend patches
Write tests
Plan data migrations
Keep modules focused
A well-designed custom module may still require changes during an Odoo upgrade.
That is normal.
The difference is that instead of spending days trying to understand what the module was doing, you can identify the affected extension points, update them, run the tests, migrate the data, and move forward.
The best custom module is not simply one that works on the version you are running today.
It is one that leaves the next developer a reasonable path to the version you will be running tomorrow.
Top comments (0)