The default move in Odoo is to extend an existing model. When you need to track something about a customer, you add a field to res.partner. When you need to track something about an order, you add a field to sale.order. Many of the bugs we inherit from prior consultants come from this reflex applied too widely.
Extending is sometimes right. Often it is not. This is a design question, not a technical one. Is the thing you are tracking genuinely an attribute of the entity you are extending, or is it a separate concept that just happens to relate to it? The two answers point to different code. Getting the answer wrong is cheap on day one and expensive on every version upgrade after.
What follows is a four-question framework for the choice, three worked examples showing the framework applied, and a note on what the wrong choice costs you on the next version upgrade.
Why extending is the default reflex
Every Odoo tutorial starts with _inherit. The first custom code most developers write extends an existing model. The Odoo ORM makes it easy: declare _inherit = "res.partner", add fields, override methods, inherit and modify views, restart, and update the module. The new fields appear on the form view automatically. The new behavior fires inside the existing workflows.
That ease is the trap. The hard part is not getting a field to appear on res.partner. The hard part is deciding whether it belongs there.
When the answer is yes, extension works well. When the answer is no, extension still works at first, and that is the problem. It ships. It passes tests. The costs show up later. The field appears on every partner record whether it applies or not. Search domains have to filter it out. Views have to hide it for the wrong contexts. Security rules have to reason about it. And every version upgrade has to check that the new version of res.partner does not interact with your additions in a way that breaks them.
The cost is invisible the day the field is added. It compounds.
The decision framework
Four questions. If the honest answer to all four is yes, extend. If any one is no, build alongside.
Is this a single fact about the record, or its own thing? A partner's credit tier is a single fact: each partner has one, it describes the partner, it fits in one field. A partner's history of credit reviews is not. Each review has its own state, reviewer, date, and notes, and a partner can have many of them over time. You cannot fit a collection of multi-attribute things into a single field on the parent record. The test: can the thing be captured as one value (or a small fixed set of values) directly on the record, or does it have its own identity, lifecycle, and audit trail? If the latter, it is not an attribute, it is an entity, and it gets its own table.
Does it apply to most records, not just a workflow-specific subset? If 90% of your partners need this field set to a meaningful value, extension is reasonable. If only the 5% of partners enrolled in a specific program need it, you are polluting the model for the other 95%. The forms get more cluttered, the queries get wider, the indexes get heavier, and the meaning of "blank" becomes ambiguous (is this partner not enrolled, or is it enrolled but the field was never set?).
Does the lifecycle match the parent? Extension assumes the extended fields live and die with the parent record. If the data has its own lifecycle (multiple instances over time for one parent, archived independently, retained after the parent is archived, created by a different process than the parent), the lifecycle mismatch will eventually surface as a bug. The field will be the wrong age, or stale, or out of sync with whatever process actually owns it.
Is the field independent of any one workflow? If a field's meaning is "the state this record is in inside our credit review workflow," that field's home is the credit review, not the entity the workflow is about. Workflows have states. Entities have attributes. Conflating them is how you end up with res.partner carrying eleven Selection fields, three of which only make sense if some other field is set to a specific value.
The four questions are easy to ask and easy to answer honestly. The discipline is asking them every time, not just when you remember.
Three patterns
Pattern one: extend. A credit tier on res.partner. Every partner has one. It is genuinely an attribute. It changes infrequently. Its lifecycle is tied to the partner. It is independent of any one workflow.
class DsResPartner(models.Model):
_inherit = "res.partner"
ds_credit_tier = fields.Selection(
[("standard", "Standard"),
("priority", "Priority"),
("hold", "Hold")],
string="Credit Tier",
default="standard",
)
One field. Defaults sensibly. Means the same thing on every partner record. Easy to upgrade.
Pattern two: build alongside. A credit review entity. A review happens periodically, has its own state, a reviewer, notes, dates. Multiple reviews exist for one partner over time. This is its own model with a many-to-one back to the partner.
class DsPartnerCreditReview(models.Model):
_name = "ds.partner.credit.review"
_description = "DimeSoft Partner Credit Review"
partner_id = fields.Many2one("res.partner", required=True)
state = fields.Selection(
[("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected")],
default="pending",
)
reviewer_id = fields.Many2one("res.users")
ds_review_date = fields.Date()
ds_notes = fields.Text()
The review has its own form view, its own list view, its own access rules, its own audit trail. The partner has a One2many back to it if you want to see reviews from the partner form, but the data lives in its own table. This is more code on day one. It is dramatically less code over five years.
Pattern three: the seductive middle ground that is wrong. You have a credit review entity (pattern two), and you decide to also add a ds_current_credit_state field directly on res.partner, "for convenience." You tell yourself it is a denormalized cache of the latest review's state.
Six months later, the views need the review date too. You add it to res.partner. Then the reviewer. Then the notes. You have now smeared the credit review entity across two models, the denormalization is wrong half the time because someone forgot to update it, and you cannot tell from the partner record alone whether the cached values are current.
The right move from the start: keep the review as its own model, and if you genuinely need the latest state on the partner record, add a computed field that reads it on demand.
class DsResPartner(models.Model):
_inherit = "res.partner"
# One2many back to the credit review entity.
# The reviews live in their own table; this is just the relation.
ds_credit_review_ids = fields.One2many(
"ds.partner.credit.review", "partner_id")
# Computed (not stored) view of the most recent review's state.
# Read on demand from the related reviews; nothing to keep in sync.
ds_latest_credit_state = fields.Selection(
[("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected")],
compute="_ds_compute_latest_credit_state",
)
@api.depends(
"ds_credit_review_ids",
"ds_credit_review_ids.state",
"ds_credit_review_ids.ds_review_date",
)
def _ds_compute_latest_credit_state(self):
# For each partner, find the review with the most recent
# review date and use its state. Partners with no reviews
# get a False value, which renders blank on the form.
for partner in self:
latest = partner.ds_credit_review_ids.sorted(
key=lambda r: r.ds_review_date or fields.Date.min,
reverse=True,
)[:1]
partner.ds_latest_credit_state = latest.state if latest else False
Computed, not stored where possible. The data has one home. The partner record reads from it. No drift.
The cost on a version upgrade
Every field you add to a core Odoo model is a field you have to validate against the new version's behavior. Search domains, views, security rules, automated actions, reports, and any custom code that touches the model all have to be re-checked. If the new version of Odoo changes how res.partner is loaded, cached, or computed, your override is part of the surface area that might break.
A separate model has a much smaller upgrade footprint. The model is yours. It depends on base and on a few referenced models, and that is it. The upgrade question for ds.partner.credit.review is "did anything we depend on change?" The upgrade question for an extended res.partner is "did anything in the entire res.partner lifecycle change?"
The first question is bounded. The second is not.
This shows up most painfully on inherited models that received many additions over the years. We have audited deployments where res.partner carried more than thirty custom fields from prior consultants, with no clear logic for which workflow each one served. An upgrade across two major Odoo versions on that codebase is not a project, it is an expedition.
An honest caveat
The framework is a guide, not a rule. There are cases where extending is the right call for a field that fails one of the four questions, because the alternative (a tiny separate model with one field) would be more infrastructure than the situation deserves. Judgment matters more than the framework.
The cost of an unnecessary separate model is real: another table, another relation, another view, another security configuration, another thing the next developer has to understand. We are not arguing for separate models everywhere. We are arguing against extension as the unexamined default.
One last thing to watch: when extension and a separate model both look defensible, the tie usually breaks toward extension because extension is less code to write today. That is not a design reason. It is just the path of least resistance, and the Odoo ORM is built to make it smooth. Let the four questions break the tie, not the keyboard.
Most Odoo codebases we audit show the same pattern: a small number of core models, extended past the point of legibility, by people who never asked whether the extension belonged. Avoiding that outcome on the work you are scoping now is mostly a matter of asking the four questions before you type _inherit. The framework above is not exotic. It is just the discipline of pausing long enough to apply it.
If you are scoping a custom Odoo module right now and the extend-versus-build-alongside question is open, the four questions above are roughly the conversation we would have with you in a scoping session. Thinking through this design before you ship it costs far less than untangling it later, and we do both. DimeSoft has been writing customizations for Odoo deployments under this kind of discipline for many years. The first conversation is a real one, not a sales pitch. Reach out if that would help.
The default move in Odoo is to extend an existing model. When you need to track something about a customer, you add a field to res.partner. When you need to track something about an order, you add a field to sale.order. Many of the bugs we inherit from prior consultants come from this reflex applied too widely.
Extending is sometimes right. Often it is not. This is a design question, not a technical one. Is the thing you are tracking genuinely an attribute of the entity you are extending, or is it a separate concept that just happens to relate to it? The two answers point to different code. Getting the answer wrong is cheap on day one and expensive on every version upgrade after.
What follows is a four-question framework for the choice, three worked examples showing the framework applied, and a note on what the wrong choice costs you on the next version upgrade.
Why extending is the default reflex
Every Odoo tutorial starts with _inherit. The first custom code most developers write extends an existing model. The Odoo ORM makes it easy: declare _inherit = "res.partner", add fields, override methods, inherit and modify views, restart, and update the module. The new fields appear on the form view automatically. The new behavior fires inside the existing workflows.
That ease is the trap. The hard part is not getting a field to appear on res.partner. The hard part is deciding whether it belongs there.
When the answer is yes, extension works well. When the answer is no, extension still works at first, and that is the problem. It ships. It passes tests. The costs show up later. The field appears on every partner record whether it applies or not. Search domains have to filter it out. Views have to hide it for the wrong contexts. Security rules have to reason about it. And every version upgrade has to check that the new version of res.partner does not interact with your additions in a way that breaks them.
The cost is invisible the day the field is added. It compounds.
The decision framework
Four questions. If the honest answer to all four is yes, extend. If any one is no, build alongside.
Is this a single fact about the record, or its own thing? A partner's credit tier is a single fact: each partner has one, it describes the partner, it fits in one field. A partner's history of credit reviews is not. Each review has its own state, reviewer, date, and notes, and a partner can have many of them over time. You cannot fit a collection of multi-attribute things into a single field on the parent record. The test: can the thing be captured as one value (or a small fixed set of values) directly on the record, or does it have its own identity, lifecycle, and audit trail? If the latter, it is not an attribute, it is an entity, and it gets its own table.
Does it apply to most records, not just a workflow-specific subset? If 90% of your partners need this field set to a meaningful value, extension is reasonable. If only the 5% of partners enrolled in a specific program need it, you are polluting the model for the other 95%. The forms get more cluttered, the queries get wider, the indexes get heavier, and the meaning of "blank" becomes ambiguous (is this partner not enrolled, or is it enrolled but the field was never set?).
Does the lifecycle match the parent? Extension assumes the extended fields live and die with the parent record. If the data has its own lifecycle (multiple instances over time for one parent, archived independently, retained after the parent is archived, created by a different process than the parent), the lifecycle mismatch will eventually surface as a bug. The field will be the wrong age, or stale, or out of sync with whatever process actually owns it.
Is the field independent of any one workflow? If a field's meaning is "the state this record is in inside our credit review workflow," that field's home is the credit review, not the entity the workflow is about. Workflows have states. Entities have attributes. Conflating them is how you end up with res.partner carrying eleven Selection fields, three of which only make sense if some other field is set to a specific value.
The four questions are easy to ask and easy to answer honestly. The discipline is asking them every time, not just when you remember.
Three patterns
Pattern one: extend. A credit tier on res.partner. Every partner has one. It is genuinely an attribute. It changes infrequently. Its lifecycle is tied to the partner. It is independent of any one workflow.
class DsResPartner(models.Model):
_inherit = "res.partner"
ds_credit_tier = fields.Selection(
[("standard", "Standard"),
("priority", "Priority"),
("hold", "Hold")],
string="Credit Tier",
default="standard",
)
One field. Defaults sensibly. Means the same thing on every partner record. Easy to upgrade.
Pattern two: build alongside. A credit review entity. A review happens periodically, has its own state, a reviewer, notes, dates. Multiple reviews exist for one partner over time. This is its own model with a many-to-one back to the partner.
class DsPartnerCreditReview(models.Model):
_name = "ds.partner.credit.review"
_description = "DimeSoft Partner Credit Review"
partner_id = fields.Many2one("res.partner", required=True)
state = fields.Selection(
[("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected")],
default="pending",
)
reviewer_id = fields.Many2one("res.users")
ds_review_date = fields.Date()
ds_notes = fields.Text()
The review has its own form view, its own list view, its own access rules, its own audit trail. The partner has a One2many back to it if you want to see reviews from the partner form, but the data lives in its own table. This is more code on day one. It is dramatically less code over five years.
Pattern three: the seductive middle ground that is wrong. You have a credit review entity (pattern two), and you decide to also add a ds_current_credit_state field directly on res.partner, "for convenience." You tell yourself it is a denormalized cache of the latest review's state.
Six months later, the views need the review date too. You add it to res.partner. Then the reviewer. Then the notes. You have now smeared the credit review entity across two models, the denormalization is wrong half the time because someone forgot to update it, and you cannot tell from the partner record alone whether the cached values are current.
The right move from the start: keep the review as its own model, and if you genuinely need the latest state on the partner record, add a computed field that reads it on demand.
class DsResPartner(models.Model):
_inherit = "res.partner"
# One2many back to the credit review entity.
# The reviews live in their own table; this is just the relation.
ds_credit_review_ids = fields.One2many(
"ds.partner.credit.review", "partner_id")
# Computed (not stored) view of the most recent review's state.
# Read on demand from the related reviews; nothing to keep in sync.
ds_latest_credit_state = fields.Selection(
[("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected")],
compute="_ds_compute_latest_credit_state",
)
@api.depends(
"ds_credit_review_ids",
"ds_credit_review_ids.state",
"ds_credit_review_ids.ds_review_date",
)
def _ds_compute_latest_credit_state(self):
# For each partner, find the review with the most recent
# review date and use its state. Partners with no reviews
# get a False value, which renders blank on the form.
for partner in self:
latest = partner.ds_credit_review_ids.sorted(
key=lambda r: r.ds_review_date or fields.Date.min,
reverse=True,
)[:1]
partner.ds_latest_credit_state = latest.state if latest else False
Computed, not stored where possible. The data has one home. The partner record reads from it. No drift.
The cost on a version upgrade
Every field you add to a core Odoo model is a field you have to validate against the new version's behavior. Search domains, views, security rules, automated actions, reports, and any custom code that touches the model all have to be re-checked. If the new version of Odoo changes how res.partner is loaded, cached, or computed, your override is part of the surface area that might break.
A separate model has a much smaller upgrade footprint. The model is yours. It depends on base and on a few referenced models, and that is it. The upgrade question for ds.partner.credit.review is "did anything we depend on change?" The upgrade question for an extended res.partner is "did anything in the entire res.partner lifecycle change?"
The first question is bounded. The second is not.
This shows up most painfully on inherited models that received many additions over the years. We have audited deployments where res.partner carried more than thirty custom fields from prior consultants, with no clear logic for which workflow each one served. An upgrade across two major Odoo versions on that codebase is not a project, it is an expedition.
An honest caveat
The framework is a guide, not a rule. There are cases where extending is the right call for a field that fails one of the four questions, because the alternative (a tiny separate model with one field) would be more infrastructure than the situation deserves. Judgment matters more than the framework.
The cost of an unnecessary separate model is real: another table, another relation, another view, another security configuration, another thing the next developer has to understand. We are not arguing for separate models everywhere. We are arguing against extension as the unexamined default.
One last thing to watch: when extension and a separate model both look defensible, the tie usually breaks toward extension because extension is less code to write today. That is not a design reason. It is just the path of least resistance, and the Odoo ORM is built to make it smooth. Let the four questions break the tie, not the keyboard.
Most Odoo codebases we audit show the same pattern: a small number of core models, extended past the point of legibility, by people who never asked whether the extension belonged. Avoiding that outcome on the work you are scoping now is mostly a matter of asking the four questions before you type _inherit. The framework above is not exotic. It is just the discipline of pausing long enough to apply it.
If you are scoping a custom Odoo module right now and the extend-versus-build-alongside question is open, the four questions above are roughly the conversation we would have with you in a scoping session. Thinking through this design before you ship it costs far less than untangling it later, and we do both. DimeSoft has been writing customizations for Odoo deployments under this kind of discipline for many years. The first conversation is a real one, not a sales pitch. Reach out if that would help.
Start writing here...