Hello everyone 👋
If you enjoyed my previous post, thank you — and if you didn’t read it, that’s totally fine. You can find it here: Odoo Core and the Cost of Reinventing Everything.
In this post, I want to highlight some quirks and questionable architectural decisions in the Odoo codebase, specifically around validation, data handling, and error management. These issues significantly increase debugging time and cognitive load, and most of them are problems that mature web frameworks solved decades ago.
As mentioned in my previous post, Odoo chose to be a custom-built framework that reimplements every layer of a modern web stack instead of building on an existing one, despite mature frameworks having solved these problems decades ago..
1. The Validation Layer (or Lack Thereof)
Validation is a foundational concept in any serious web framework. In mature ecosystems, validation is:
- Declarative
- Centralized
- Separated from authorization and business logic
Odoo, however:
- Has no dedicated validation layer
- Does not use any standard validation library (e.g. Pydantic, Marshmallow)
- Spreads validation logic across models, controllers, and JavaScript
- Frequently mixes validation, authorization, and state transitions
The result is deeply nested, brittle code that is difficult to reason about.
Here’s a real example from the Odoo codebase that validates whether a user can change the state of a leave request:
# https://github.com/odoo/odoo/blob/19.0/addons/hr_holidays/models/hr_leave.py#L1361
def _check_approval_update(self, state, raise_if_not_possible=True):
""" Check if target state is achievable. """
if self.env.is_superuser():
return True
is_officer = self.env.user.has_group('hr_holidays.group_hr_holidays_user')
for holiday in self:
is_time_off_manager = holiday.employee_id.leave_manager_id == self.env.user
dict_all_possible_state = holiday._get_next_states_by_state()
validation_type = holiday.validation_type
error_message = ""
# Standard Check
if holiday.state == state:
error_message = self.env._('You can\'t do the same action twice.')
elif state == 'validate1' and validation_type != 'both':
error_message = self.env._('Not possible state. State Approve is only used for leave needed 2 approvals')
elif holiday.state == 'cancel':
error_message = self.env._('A cancelled leave cannot be modified.')
elif state not in dict_all_possible_state.get(holiday.state, {}):
if state == 'cancel':
error_message = self.env._('You can only cancel your own leave. You can cancel a leave only if this leave \
is approved, validated or refused.')
elif state == 'confirm':
error_message = self.env._('You can\'t reset a leave. Cancel/delete this one and create an other')
elif state == 'validate1':
if not is_time_off_manager:
error_message = self.env._('Only a Time Off Officer/Manager can approve a leave.')
else:
error_message = self.env._('You can\'t approve a validated leave.')
elif state == "validate":
if not is_time_off_manager:
error_message = self.env._('Only a Time Off Officer/Manager can validate a leave.')
elif holiday.state == "refuse":
error_message = self.env._('You can\'t approve this refused leave.')
else:
error_message = self.env._('You can only validate a leave with validation by Time Off Manager.')
elif state == "refuse":
if not is_time_off_manager:
error_message = self.env._('Only a Time Off Officer/Manager can refuse a leave.')
else:
error_message = self.env._('You can\'t refuse a leave with validation by Time Off Officer.')
elif state != "cancel":
try:
holiday.check_access('write')
except UserError as e:
if raise_if_not_possible:
raise UserError(e)
return False
else:
continue
if error_message:
if raise_if_not_possible:
raise UserError(error_message)
return False
return True
This function:
- Validates state transitions
- Performs authorization checks
- Constructs user-facing error messages
- Raises HTTP-facing exceptions
- Depends on implicit global context
All of this happens in a single method.
In a modern framework, this logic would be split into:
- A state machine
- A validation schema
- A permission layer
- A controller-level response mapper
Because Odoo lacks these abstractions, developers are forced to manually stitch everything together — leading to massive functions like this one and endless debugging sessions.
2. Data Type Handling (or the Absence of Normalization)
Data normalization is another area where mature frameworks excel. Incoming HTTP data is:
- Parsed
- Typed
- Validated
- Normalized before business logic runs
Odoo does none of this.
Consider a simple HTTP controller:
from odoo.http import request
class MyController(http.Controller):
@http.route('/hello/user', type="http", auth=False, csrf=False)
def say_hello_user(self, **request_body):
if request.httprequest.method == "POST":
name = request_body.get("name", False)
if name == False:
name = "Boga"
return request.render(
'my_module.say_hello',
{'name': f"Hello, {name}"}
)
And a simple form:
<form method="post">
<input placeholder="enter your name" name="name" />
<button type="submit">Say Hi</button>
</form>
If the user clicks “Say Hi” without entering a name, the value of name will be:
""
Not False.
So now we’re forced to write:
if name is False or name == "":
name = "Boga"
Why does this matter?
Because Odoo never normalizes request data. The same field can be:
- Missing (
False) - Present but empty (
"") - Present with a value
All three cases must be handled manually, everywhere.
And yes — checking for False is still necessary, because a client could send an empty POST body:
fetch("/hello/user", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: ""
})
Now request_body.get("name", False) returns False.
In frameworks like Django, FastAPI, or Flask + WTForms, this problem simply does not exist.
3. Error Handling: The Most Fragile Part
Error handling in Odoo HTTP is arguably its weakest point.
Errors are:
- Globally intercepted
- Tightly coupled to translation logic
- Often returned with HTTP 200 responses
- Extremely difficult to override or customize correctly
This leads to unpredictable behavior where:
- A response looks successful
- But actually contains an error payload
- Or silently redirects
Consider this simplified example:
# my_service.py
class MyCustomService:
def check_balance(model_id, user):
try:
send_http_request("/some/other/service", {
"id": model_id,
"user": user
})
except HttpClient.not_found as e:
return e
# my_controller.py
class MyController(http.Controller):
@http.route('/check/balance', type="http", auth=True, csrf=True)
def check_user_balance(self, **request_body):
try:
user = request.env.user
my_service.check_balance(user)
except InvalidBalance:
return request.redirect('/invalid/balance')
return request.render('my_module.success')
This code will not behave as expected, because Odoo’s global HTTP error handling will intercept exceptions before your controller logic can respond properly.
Once again, the lack of a clean separation between:
- Transport errors
- Business exceptions
- HTTP responses
Makes even simple flows unreliable.
Conclusion
Odoo tries to be:
- An ORM
- A web framework
- A frontend framework
- A business platform
But it lacks the architectural discipline required to do any of these well.
The absence of:
- A real validation layer
- Request data normalization
- Predictable error handling
Means developers spend far more time debugging framework behavior than implementing business logic.
None of these problems are unsolved — they were simply re-solved poorly.
Top comments (2)
This sounds not like the cost of reinventing the web stack, but rather reinventing it poorly.
Since many other web stacks aren't exactly perfect either and have several other issues, given the right amount of experience, including the points you listed here, a few more attempts, it should be perfectly possible to create a better stack than any of the existing ones, maybe even solving new problems that haven't been solved by other frameworks yet and given their architecture or architectural choices, maybe never will.
So, in summary, the journey you described is how various other stacks and frameworks have been created. That's evolution.
Hi @dariomannu , thank you for taking the time to read the post and share a thoughtful perspective. I agree that many successful frameworks were born through iteration, experimentation, and even early mistakes — that’s how evolution works in software.
The difference with Odoo, however, is not that it reinvented the web stack, but that after an extremely long lifecycle and an enormous amount of development effort (approaching nearly 200K commits, including a major Python 3 rewrite), many fundamental framework concerns are still unresolved — validation boundaries, consistent data normalization, predictable error handling, and clear separation of responsibilities.
At that point, the issue is less about early-stage evolution and more about architectural inertia. Some design choices make it increasingly difficult to introduce these fundamentals without breaking large parts of the ecosystem. This is where cost becomes visible: long-term maintenance overhead, debugging complexity, and a degraded developer experience.
Evolution requires the ability to correct fundamentals. If fundamentals remain missing after massive iteration, the problem is architectural, not evolutionary.
I’ve intentionally focused only on surface-level issues so far. There are deeper architectural choices I haven’t covered yet, and some of them raise even more serious concerns. My hope isn’t to dismiss Odoo, but that these trade-offs are acknowledged — either by the Odoo team addressing them directly, or by future projects learning from them and building something better while accounting for these shortcomings.