<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Borovlev Artem</title>
    <description>The latest articles on Forem by Borovlev Artem (@borovlevas).</description>
    <link>https://forem.com/borovlevas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1951419%2F7e2f68fb-81f8-4b56-81d4-389633e4bade.jpeg</url>
      <title>Forem: Borovlev Artem</title>
      <link>https://forem.com/borovlevas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/borovlevas"/>
    <language>en</language>
    <item>
      <title>Beyond the Syntax: How onchange, depends and inverse Really Work in Odoo</title>
      <dc:creator>Borovlev Artem</dc:creator>
      <pubDate>Sun, 01 Mar 2026 08:59:29 +0000</pubDate>
      <link>https://forem.com/borovlevas/beyond-the-syntax-how-onchange-depends-and-inverse-really-work-in-odoo-5ei8</link>
      <guid>https://forem.com/borovlevas/beyond-the-syntax-how-onchange-depends-and-inverse-really-work-in-odoo-5ei8</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Ask any Odoo developer to explain the difference between &lt;code&gt;@api.depends&lt;/code&gt; and &lt;code&gt;@api.onchange&lt;/code&gt;, and you'll get some version of the same answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"onchange is for the UI - it fires when the user changes a field in the form. depends is for computed fields - it runs when you save."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's not wrong. But it's incomplete in ways that matter. And that incompleteness quietly causes bugs, unnecessary code, wasted debugging hours, and team arguments about why something "sometimes works and sometimes doesn't."&lt;/p&gt;

&lt;p&gt;Here's what that incomplete understanding looks like in practice: a developer notices that a computed field isn't updating in the form view, so they add &lt;code&gt;@api.onchange&lt;/code&gt; on top of &lt;code&gt;@api.depends&lt;/code&gt;. It works. They ship it. Nobody questions it. Six months later someone else looks at that code and wonders: &lt;em&gt;do we need both? what does each one actually do here?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Or a developer implements two fields that should stay in sync - change one, the other updates automatically. They set up &lt;code&gt;compute&lt;/code&gt; and &lt;code&gt;inverse&lt;/code&gt;. It works on save. But in the form view, it doesn't update until after saving. They're confused: isn't &lt;code&gt;inverse&lt;/code&gt; supposed to handle this?&lt;/p&gt;

&lt;p&gt;These aren't edge cases. They come up regularly, and they come up because the standard explanation - &lt;em&gt;"onchange is UI, depends is on save"&lt;/em&gt; - doesn't tell you what's actually happening inside Odoo's ORM.&lt;/p&gt;

&lt;p&gt;This article does.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This research is based on Odoo 17 source code. The core mechanics described here apply equally to Odoo 16 and 18 - the underlying architecture hasn't changed.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Interview Answer (And Why It's Not Enough)
&lt;/h2&gt;

&lt;p&gt;The standard explanation maps roughly to this mental model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User changes field in form
        ↓
   @api.onchange fires   ← "UI stuff"
        ↓
   User clicks Save
        ↓
   @api.depends fires    ← "DB stuff"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model isn't completely wrong - it just describes &lt;em&gt;what&lt;/em&gt; happens, not &lt;em&gt;how&lt;/em&gt; or &lt;em&gt;why&lt;/em&gt;. And once you start doing anything beyond the basics, the "what" stops being enough.&lt;/p&gt;

&lt;p&gt;Consider these questions that the standard answer can't address:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;@api.depends&lt;/code&gt; only fires on save, why does a computed field sometimes update in the form view &lt;em&gt;before&lt;/em&gt; saving?&lt;/li&gt;
&lt;li&gt;If you have &lt;code&gt;@api.depends&lt;/code&gt; and &lt;code&gt;@api.onchange&lt;/code&gt; on the same method, are they doing the same thing? Different things? Is one redundant?&lt;/li&gt;
&lt;li&gt;Why does &lt;code&gt;inverse&lt;/code&gt; work perfectly when you call &lt;code&gt;write()&lt;/code&gt; in code, but nothing happens when the user edits the field in the form?&lt;/li&gt;
&lt;li&gt;Why does removing &lt;code&gt;@api.onchange&lt;/code&gt; from a computed field sometimes break the UI - and sometimes change nothing at all?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To answer these, you need to understand what actually happens when a user changes a field value in an Odoo form. Not conceptually - mechanically.&lt;/p&gt;

&lt;p&gt;Let's go through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Happens When a User Changes a Field
&lt;/h2&gt;

&lt;p&gt;When a user modifies a field value in an Odoo form view, the browser sends an RPC call to the server - specifically to the &lt;code&gt;onchange()&lt;/code&gt; method in &lt;code&gt;web/models/models.py&lt;/code&gt;. What happens inside that method is more nuanced than it appears.&lt;/p&gt;

&lt;p&gt;Here's the high-level sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. flush_all()                   - clear any pending computations from before
2. record = self.new(values)     - create a virtual record (no database ID)
3. snapshot0                     - capture the initial state of all fields
4. _update_cache(changed_values) - apply the user's change to the virtual record
5. modified(changed_fields)      - register which fields need recomputation
6. @api.onchange loop            - the real action happens here (see below)
7. snapshot1                     - capture the final state of all fields
8. diff(snapshot0, snapshot1)    - return only what changed to the browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;First, an important detail: the record has no database ID.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Odoo creates a virtual record - called a &lt;code&gt;NewId&lt;/code&gt; record - to process the onchange. It exists only in memory. Nothing is written to the database at this point. This isn't a minor implementation detail - it's fundamental to understanding why certain things behave differently in onchange vs. write/create. We'll come back to this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now, step 5: what does &lt;code&gt;modified()&lt;/code&gt; actually do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modified()&lt;/code&gt; is an internal ORM method that most developers never call directly and rarely think about. When Odoo calls &lt;code&gt;record.modified(changed_fields)&lt;/code&gt;, it doesn't recompute anything. It simply walks the dependency graph - the relationships declared through &lt;code&gt;@api.depends&lt;/code&gt; - and registers which computed fields are now outdated. Those fields get added to an internal queue called &lt;code&gt;tocompute&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's it. No computation. Just bookkeeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real action is in step 6 - the onchange loop.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;modified()&lt;/code&gt; runs, Odoo enters a loop that processes &lt;code&gt;@api.onchange&lt;/code&gt; methods for each changed field. But the interesting part isn't the onchange methods themselves - it's what happens between iterations.&lt;/p&gt;

&lt;p&gt;After each onchange method runs, Odoo needs to figure out: &lt;em&gt;did anything else change as a result?&lt;/em&gt; To do that, it reads the current value of every field in the form spec and compares it to the snapshot. And here's the key: &lt;strong&gt;reading a field triggers &lt;code&gt;__get__()&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;__get__()&lt;/code&gt; is Python's descriptor protocol - it's called whenever you access an attribute on an object. Odoo's field implementation hooks into this. Inside &lt;code&gt;__get__()&lt;/code&gt;, before returning a value, Odoo checks: is this field in the &lt;code&gt;tocompute&lt;/code&gt; queue? If yes - compute it now.&lt;/p&gt;

&lt;p&gt;So the actual execution of your &lt;code&gt;@api.depends&lt;/code&gt; method happens here, inside the loop, triggered by reading the field to check if it changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;onchange method runs for field_x
    ↓
Odoo checks: what else changed?
    ↓
reads field_y value  →  __get__()  →  field_y is in tocompute?
    ↓
YES  →  your @api.depends method runs right here
    ↓
field_y now has new value  →  it changed  →  added to next loop iteration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cascading behavior is what makes the onchange system reactive. A user changes one field, an onchange method updates a second field, &lt;code&gt;@api.depends&lt;/code&gt; recomputes a third - and all of this happens in a single RPC call, before any data touches the database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;snapshot1&lt;/code&gt; in step 7 is just the final read of all fields after the loop completes. By that point, everything that needed computing has already been computed.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;&lt;code&gt;@api.depends&lt;/code&gt; does not fire "on save" as a standalone event.&lt;/strong&gt; The method runs when the field is read (lazy evaluation via &lt;code&gt;__get__()&lt;/code&gt;), or when a flush occurs - which is what actually writes computed values to the database. During onchange, the read happens inside the loop. Outside of onchange, recomputation happens when the field is read or when a flush is called - for example at the end of a transaction.&lt;/p&gt;

&lt;p&gt;The "on save" mental model collapses two separate things into one: the moment the compute method runs, and the moment the result reaches the database. They're not the same event.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where inverse Fits In - And When It Doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;inverse&lt;/code&gt; is Odoo's mechanism for making a computed field writable. When you define a computed field with an &lt;code&gt;inverse&lt;/code&gt; method, you're telling Odoo: &lt;em&gt;"if someone writes to this field directly, here's what to do with that value."&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;amount_currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Monetary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;compute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_compute_amount_currency&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;inverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_inverse_amount_currency&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_inverse_amount_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# env.is_protected() checks whether the field is currently being
&lt;/span&gt;        &lt;span class="c1"&gt;# recomputed by the ORM - a safeguard to prevent circular updates
&lt;/span&gt;        &lt;span class="c1"&gt;# when inverse and compute methods interact with each other.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_protected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;balance&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_currency&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency_rate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inverse&lt;/code&gt; has exactly two entry points in the ORM: &lt;code&gt;write()&lt;/code&gt; and &lt;code&gt;create()&lt;/code&gt;. That's it. There is no other place in the framework where inverse methods are called.&lt;/p&gt;

&lt;p&gt;This means: when a user changes a field in the form view and the onchange RPC fires - &lt;code&gt;inverse&lt;/code&gt; is never involved. onchange doesn't go through &lt;code&gt;write()&lt;/code&gt;. It works directly with the record cache via &lt;code&gt;_update_cache()&lt;/code&gt;. The entire onchange pipeline - applying changes, triggering dependencies, running handlers - happens without touching &lt;code&gt;write()&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;So if you're expecting an inverse method to fire because a user edited a field in the UI, that expectation is wrong by design. &lt;code&gt;inverse&lt;/code&gt; is a persistence mechanism. It runs when data is being committed - not when a user is still filling out a form.&lt;/p&gt;

&lt;p&gt;There's another layer to this. Remember that onchange works on a &lt;code&gt;NewId&lt;/code&gt; record - a virtual record with no database ID. Even if you somehow called &lt;code&gt;write()&lt;/code&gt; manually inside an onchange handler, &lt;code&gt;write()&lt;/code&gt; filters records before calling inverse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;real_recs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# NewId records are excluded here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter exists because &lt;code&gt;write()&lt;/code&gt; can be called in contexts where both real and virtual records are mixed. It's a safeguard, not a limitation.&lt;/p&gt;

&lt;p&gt;The practical consequence: &lt;strong&gt;&lt;code&gt;inverse&lt;/code&gt; handles the database side of a field. For anything that needs to happen in the UI before saving, you need a different tool&lt;/strong&gt; - which is what the next section covers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Right Tools for the Right Job
&lt;/h2&gt;

&lt;p&gt;Now that we understand how each mechanism works, the choice between them becomes clearer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@api.depends&lt;/code&gt;&lt;/strong&gt; declares a computational relationship between fields. It tells Odoo: when these source fields change, mark this computed field as outdated. The actual recomputation happens later - when something reads the field. In the context of onchange, that means the field updates in the UI only if Odoo reads it during the onchange pipeline (which happens when the field is part of the form's field spec). But &lt;code&gt;@api.depends&lt;/code&gt; itself is not a UI mechanism - it's a dependency declaration. Without something triggering a read, the computed value stays stale until the next database flush.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@api.onchange&lt;/code&gt;&lt;/strong&gt; is an event handler that the framework calls automatically when a user changes a field in the form view. The key word is &lt;em&gt;automatically&lt;/em&gt; - Odoo's onchange pipeline picks up these methods and runs them. You can call them manually from code too, and they'll work fine. But the framework won't call them for you outside of a UI context. Use &lt;code&gt;@api.onchange&lt;/code&gt; for things that are inherently about the editing experience: showing warnings, setting default values, recalculating fields, or any other logic that should react to user input in the form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;inverse&lt;/code&gt;&lt;/strong&gt; is a persistence mechanism. Use it when a computed field needs to be writable and changing it should propagate back to the source data - but only at save time, only for records with real database IDs.&lt;/p&gt;

&lt;p&gt;A practical way to think about it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Triggered by&lt;/th&gt;
&lt;th&gt;Updates UI before save&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@api.depends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read (lazily, after field is marked outdated)&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@api.onchange&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;onchange RPC (user edits in form)&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inverse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write / create&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Beyond the trigger: how these methods actually behave differently.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Understanding &lt;em&gt;when&lt;/em&gt; each method fires is only part of the picture. They also behave differently in terms of what they receive and what they can return.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;@api.onchange&lt;/code&gt; is triggered from the UI, &lt;code&gt;self&lt;/code&gt; is always a recordset of exactly one record - the one the user is editing. In &lt;code&gt;@api.depends&lt;/code&gt;, &lt;code&gt;self&lt;/code&gt; can be a batch of multiple records, which is why you always iterate with &lt;code&gt;for record in self&lt;/code&gt;. In onchange that loop still works fine, but it's good to know the guarantee is there.&lt;/p&gt;

&lt;p&gt;More importantly: &lt;code&gt;@api.depends&lt;/code&gt; methods don't return anything. They compute a value and assign it directly to the field. That's the entire contract.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@api.onchange&lt;/code&gt; methods can optionally return a dictionary. In Odoo 17, the framework processes two keys from that return value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;new_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...},&lt;/span&gt;  &lt;span class="c1"&gt;# assign field values
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Something to note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Details here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dialog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# or "notification"
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;value&lt;/code&gt; lets you set other field values from inside the method - though assigning directly via &lt;code&gt;self.field = value&lt;/code&gt; does the same thing and is more common. &lt;code&gt;warning&lt;/code&gt; shows a dialog or notification to the user. Both keys are optional, and returning nothing at all is perfectly valid.&lt;/p&gt;

&lt;p&gt;This return structure is unique to &lt;code&gt;@api.onchange&lt;/code&gt;. There's no equivalent in &lt;code&gt;@api.depends&lt;/code&gt; - computed fields communicate their result by assigning to &lt;code&gt;self&lt;/code&gt;, not by returning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the double decorator question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You'll sometimes see code like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@api.depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;partner_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@api.onchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;partner_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_compute_payment_term&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payment_term_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partner_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;property_payment_term_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't always wrong - but it's worth asking: &lt;em&gt;why are both decorators here?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@api.depends&lt;/code&gt; already causes this method to run during onchange, via the &lt;code&gt;tocompute&lt;/code&gt; → &lt;code&gt;__get__()&lt;/code&gt; mechanism we described. Adding &lt;code&gt;@api.onchange&lt;/code&gt; on top means the method runs twice during an onchange RPC: once triggered by &lt;code&gt;@api.depends&lt;/code&gt; through the dependency system, and once explicitly by the onchange loop. In most cases, one of them is redundant.&lt;/p&gt;

&lt;p&gt;The legitimate reason to have both is when you need &lt;code&gt;@api.onchange&lt;/code&gt; specifically - to show a warning or handle UI-specific logic alongside a computed field. In that case, keep them as two separate methods: one decorated with &lt;code&gt;@api.depends&lt;/code&gt; for the computation, one with &lt;code&gt;@api.onchange&lt;/code&gt; for the UI side effects. Technically you can combine them, and it will work - but a method that both computes a field value and returns a warning is doing two different things. That makes it harder to read, harder to override in a subclass, and harder to reason about.&lt;/p&gt;

&lt;p&gt;If you can't articulate why both decorators are needed on the same method, one of them probably shouldn't be there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When things interact: a real example.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The clearest illustration of all three mechanisms working (and conflicting) together is bidirectional computed fields - where two fields each depend on the other, and changing either one should update the other in both the UI and the database.&lt;/p&gt;

&lt;p&gt;This is exactly the problem I ran into and wrote about previously. The solution requires understanding that &lt;code&gt;@api.onchange&lt;/code&gt; handles the UI reactivity, &lt;code&gt;inverse&lt;/code&gt; handles the database sync, &lt;code&gt;@api.depends&lt;/code&gt; keeps computed values current, and context flags are needed to prevent infinite recomputation loops - because each mechanism operates in a different scope and none of them alone is sufficient.&lt;/p&gt;

&lt;p&gt;If you want to see these mechanics applied to a concrete problem with rounding drift on top, that writeup is &lt;a href="https://dev.to/borovlevas/circular-recomputation-and-rounding-drift-in-odoo-12l1"&gt;here&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The standard interview answer - &lt;em&gt;"onchange is for the UI, depends is for save"&lt;/em&gt; - isn't wrong. But it describes symptoms, not causes. And when you're debugging unexpected behavior or designing a non-trivial field interaction, symptoms aren't enough.&lt;/p&gt;

&lt;p&gt;What actually matters is understanding the machinery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@api.depends&lt;/code&gt; declares a dependency. The framework marks dependent fields as outdated when inputs change, and recomputes them lazily - when something reads the field. In the onchange pipeline that happens inside the loop, not at save time.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@api.onchange&lt;/code&gt; is an event handler that the framework calls automatically from the UI. It receives a single record, can assign field values, and can return a warning. Nothing more is processed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inverse&lt;/code&gt; is called exclusively from &lt;code&gt;write()&lt;/code&gt; and &lt;code&gt;create()&lt;/code&gt;. It never runs during onchange, because onchange doesn't go through those methods.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you understand this, a lot of previously confusing behavior becomes predictable. You know why a computed field updates in the form without saving. You know why &lt;code&gt;inverse&lt;/code&gt; doesn't fire when a user edits a field. You know why adding &lt;code&gt;@api.onchange&lt;/code&gt; on top of &lt;code&gt;@api.depends&lt;/code&gt; sometimes changes nothing - and sometimes matters.&lt;/p&gt;

&lt;p&gt;And when you see &lt;code&gt;@api.depends&lt;/code&gt; and &lt;code&gt;@api.onchange&lt;/code&gt; on the same method, you know the right question to ask: &lt;em&gt;what exactly is each decorator doing here, and does this method need both?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, the mechanics described here are put to work in a concrete problem in my previous article on &lt;a href="https://dev.to/borovlevas/circular-recomputation-and-rounding-drift-in-odoo-12l1"&gt;circular recomputation and rounding drift in Odoo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>odoo</category>
      <category>erp</category>
      <category>python</category>
      <category>backend</category>
    </item>
    <item>
      <title>Circular recomputation and rounding drift in Odoo</title>
      <dc:creator>Borovlev Artem</dc:creator>
      <pubDate>Sun, 01 Mar 2026 08:51:45 +0000</pubDate>
      <link>https://forem.com/borovlevas/circular-recomputation-and-rounding-drift-in-odoo-12l1</link>
      <guid>https://forem.com/borovlevas/circular-recomputation-and-rounding-drift-in-odoo-12l1</guid>
      <description>&lt;h2&gt;
  
  
  The Real Problem
&lt;/h2&gt;

&lt;p&gt;In ERP systems, it’s common to have two fields that represent the same value in different units — for example, a purchase price in company currency and the same price in USD.&lt;/p&gt;

&lt;p&gt;Both fields are editable. Both must stay synchronized.&lt;/p&gt;

&lt;p&gt;For simplicity, let’s assume the conversion logic looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;purchase_price_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;purchase_price&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;purchase_price&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;purchase_price_usd&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The formulas here are intentionally simplified.&lt;br&gt;
In real systems you would use proper currency utilities, precision handling, and framework helpers.&lt;br&gt;
The goal of this example is to demonstrate the recomputation problem, not currency implementation details.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now consider a basic case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rate = 3&lt;/li&gt;
&lt;li&gt;user enters &lt;code&gt;purchase_price = 10&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system calculates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;purchase_price_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.33&lt;/span&gt;
&lt;span class="n"&gt;purchase_price&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.33&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;9.99&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the original value changes from 10 to 9.99.&lt;/p&gt;

&lt;p&gt;Mathematically, this is correct.&lt;br&gt;
From a user perspective, it is not.&lt;/p&gt;

&lt;p&gt;The user entered 10. The system should not silently rewrite it.&lt;/p&gt;

&lt;p&gt;This is not a floating-point bug.&lt;br&gt;
This is not a rounding bug.&lt;br&gt;
This is a consequence of combining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bidirectional field derivation&lt;/li&gt;
&lt;li&gt;rounding&lt;/li&gt;
&lt;li&gt;automatic recomputation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And this is where circular recomputation begins to break user intent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why It Happens: The Onchange Loop
&lt;/h2&gt;

&lt;p&gt;In Odoo, editable forms rely heavily on &lt;code&gt;onchange&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When a user modifies a field in the UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The frontend sends the changed value to the server.&lt;/li&gt;
&lt;li&gt;Odoo creates a temporary record snapshot.&lt;/li&gt;
&lt;li&gt;The modified field is marked as changed.&lt;/li&gt;
&lt;li&gt;All related &lt;code&gt;@api.onchange&lt;/code&gt; and &lt;code&gt;@api.depends&lt;/code&gt; logic is executed.&lt;/li&gt;
&lt;li&gt;If any additional fields change during that process, Odoo runs another pass.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This continues until no more fields are considered “changed”.&lt;/p&gt;

&lt;p&gt;This behavior is correct and intentional. It ensures consistency across dependent fields.&lt;/p&gt;

&lt;p&gt;However, in a bidirectional setup like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;purchase_price&lt;/code&gt; → updates &lt;code&gt;purchase_price_usd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;purchase_price_usd&lt;/code&gt; → updates &lt;code&gt;purchase_price&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the mechanism becomes symmetrical.&lt;/p&gt;

&lt;p&gt;When the user changes &lt;code&gt;purchase_price&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;purchase_price_usd&lt;/code&gt; is recomputed&lt;/li&gt;
&lt;li&gt;that recomputation modifies &lt;code&gt;purchase_price&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the system detects a change&lt;/li&gt;
&lt;li&gt;another pass begins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if the difference is only a rounding adjustment, it is still considered a change.&lt;/p&gt;

&lt;p&gt;The framework does not know which field represents the original user intent.&lt;/p&gt;

&lt;p&gt;It only sees that values differ - and tries to synchronize them.&lt;/p&gt;

&lt;p&gt;This is how circular recomputation emerges.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rounding as the Hidden Amplifier
&lt;/h2&gt;

&lt;p&gt;Circular recomputation alone is not always visible.&lt;/p&gt;

&lt;p&gt;The real problem becomes obvious when rounding is involved.&lt;/p&gt;

&lt;p&gt;In bidirectional transformations, we implicitly assume that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f⁻¹(f(x)) = x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But once rounding is applied, this is no longer true.&lt;/p&gt;

&lt;p&gt;With rounding to two decimal places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;≠&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the difference is only 0.01, the system detects it as a real change.&lt;/p&gt;

&lt;p&gt;From the framework’s perspective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the value is different&lt;/li&gt;
&lt;li&gt;therefore it must be synchronized&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the user’s perspective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the value they entered was rewritten&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rounding does not create the loop.&lt;br&gt;
It makes the loop observable.&lt;/p&gt;

&lt;p&gt;Without rounding, tiny floating-point differences might still exist, but they often remain invisible at the UI level.&lt;/p&gt;

&lt;p&gt;With rounding, the drift becomes explicit - and user intent gets overridden.&lt;/p&gt;

&lt;p&gt;This is where mathematical correctness collides with practical ERP behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the Usual Approaches Don’t Solve It
&lt;/h2&gt;

&lt;p&gt;It is entirely possible to implement bidirectional synchronization using &lt;code&gt;@api.depends&lt;/code&gt; and &lt;code&gt;inverse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Many Odoo modules use context flags inside inverse methods to prevent recursive writes. At the database level, this approach works reliably.&lt;/p&gt;

&lt;p&gt;The real limitation appears at the UI level.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;compute + inverse&lt;/code&gt; ensures consistency when the record is written. But it does not guarantee immediate synchronization while the user is editing a form.&lt;/p&gt;

&lt;p&gt;In the form view, updates happen through onchange.&lt;/p&gt;

&lt;p&gt;When a field is modified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Odoo builds a temporary snapshot of the record&lt;/li&gt;
&lt;li&gt;marks the changed fields&lt;/li&gt;
&lt;li&gt;executes related &lt;code&gt;onchange&lt;/code&gt; and compute logic&lt;/li&gt;
&lt;li&gt;checks which fields were modified&lt;/li&gt;
&lt;li&gt;runs additional passes if necessary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this happens inside a single &lt;code&gt;onchange&lt;/code&gt; execution cycle.&lt;/p&gt;

&lt;p&gt;There is no new request.&lt;br&gt;
There is no fresh evaluation context.&lt;br&gt;
The same record snapshot is iteratively processed until no further changes are detected.&lt;/p&gt;

&lt;p&gt;This is important.&lt;/p&gt;

&lt;p&gt;If two fields update each other, and rounding produces even a small difference, the framework sees it as a real modification. The loop continues.&lt;/p&gt;

&lt;p&gt;So while &lt;code&gt;compute + inverse&lt;/code&gt; can keep values consistent at write time, they do not solve the UI-level circular recomputation problem.&lt;/p&gt;

&lt;p&gt;To preserve user intent during form editing, direction must be controlled inside the &lt;code&gt;onchange&lt;/code&gt; cycle itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Controlling Direction Inside the Onchange Cycle
&lt;/h2&gt;

&lt;p&gt;The core insight is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At any given moment, only one field represents user intent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When a user edits &lt;code&gt;purchase_price&lt;/code&gt;, that value must be treated as authoritative.&lt;/p&gt;

&lt;p&gt;The synchronized field (&lt;code&gt;purchase_price_usd&lt;/code&gt;) should be updated - but the original field must not be touched again during the same cycle.&lt;/p&gt;

&lt;p&gt;And vice versa.&lt;/p&gt;

&lt;p&gt;The problem is that the &lt;code&gt;onchange&lt;/code&gt; loop treats both fields symmetrically. It only sees value differences and tries to keep them aligned.&lt;/p&gt;

&lt;p&gt;So we need to break that symmetry.&lt;/p&gt;

&lt;p&gt;The solution is not to disable recomputation.&lt;/p&gt;

&lt;p&gt;The solution is to control direction.&lt;/p&gt;

&lt;p&gt;In practice, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detect which field triggered the onchange.&lt;/li&gt;
&lt;li&gt;Set a context flag indicating the direction of synchronization.&lt;/li&gt;
&lt;li&gt;Prevent the reverse update during the same cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;onchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields_spec&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purchase_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;field_names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip_purchase_price_recompute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purchase_price_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;field_names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip_purchase_price_usd_recompute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;onchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields_spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the corresponding compute methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@api.depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purchase_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_compute_purchase_price_usd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip_purchase_price_usd_recompute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchase_price_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not change the mathematical model. It changes the execution model.&lt;/p&gt;

&lt;p&gt;Instead of letting both fields fight for dominance, we explicitly define which side is the source of truth for this specific user action.&lt;/p&gt;

&lt;p&gt;The rounding still exists.&lt;br&gt;
The formulas remain symmetrical.&lt;br&gt;
But the recomputation is no longer circular.&lt;br&gt;
User intent is preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Bidirectional field synchronization combined with rounding inevitably breaks mathematical reversibility.&lt;/p&gt;

&lt;p&gt;The framework is not wrong.&lt;br&gt;
The formulas are not wrong.&lt;br&gt;
The issue arises from treating both fields as equally authoritative during UI recomputation.&lt;/p&gt;

&lt;p&gt;In Odoo’s &lt;code&gt;onchange&lt;/code&gt; cycle, any detected difference triggers another evaluation pass. With rounding involved, even minimal deviations are treated as real changes.&lt;/p&gt;

&lt;p&gt;The only reliable way to preserve user intent is to control direction explicitly.&lt;/p&gt;

&lt;p&gt;At any given moment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one field must be considered the source of truth&lt;/li&gt;
&lt;li&gt;the other must be derived&lt;/li&gt;
&lt;li&gt;and reverse recomputation must be suppressed within the same evaluation cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a workaround. It is an architectural constraint of bidirectional derived fields in UI-driven systems.&lt;/p&gt;

</description>
      <category>python</category>
      <category>odoo</category>
      <category>erp</category>
      <category>backend</category>
    </item>
    <item>
      <title>Reusable Dev Environment for Odoo using Dev Containers &amp; Base Image</title>
      <dc:creator>Borovlev Artem</dc:creator>
      <pubDate>Sat, 14 Feb 2026 15:13:14 +0000</pubDate>
      <link>https://forem.com/borovlevas/reusable-dev-environment-for-odoo-using-dev-containers-base-image-1g0</link>
      <guid>https://forem.com/borovlevas/reusable-dev-environment-for-odoo-using-dev-containers-base-image-1g0</guid>
      <description>&lt;p&gt;Working on multiple Odoo projects, I often ran into the same problems — different library versions, different Python interpreters, and different PostgreSQL setups. If you only work with one Odoo version (say, Odoo 14), that’s not a big deal. But once you have to maintain Odoo 14, 15, 17, and 18 — each requiring its own Python and PostgreSQL — the environment setup quickly becomes a mess.&lt;/p&gt;

&lt;p&gt;On top of that, onboarding new developers was painful. Explaining how to set up each project, which Python to use, which dependencies to install, and how to connect to the right database — all of that took time and nerves.&lt;/p&gt;

&lt;p&gt;For development I use &lt;strong&gt;VS Code&lt;/strong&gt;, because it lets me work with different technologies in one consistent interface. After reading about &lt;strong&gt;Dev Containers&lt;/strong&gt;, I realized they could solve exactly these issues — reproducible environments, isolated dependencies, and simple onboarding.&lt;/p&gt;

&lt;p&gt;So I built a two-repository system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one for a &lt;strong&gt;reusable base Docker image&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;and another for a &lt;strong&gt;developer project template&lt;/strong&gt; using VS Code Dev Containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, they give me a single command to spin up a full Odoo environment — Odoo + Postgres + mounted code + debugger — ready to work.&lt;/p&gt;

&lt;p&gt;In this article, I’ll explain the reasoning behind this setup, the structure of both repositories, and how you can use the same approach for your own projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;My setup is split into two repositories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base image repository&lt;/strong&gt; (&lt;a href="//github.com/BorovlevAS/dev_odoo_base_docker"&gt;github.com/BorovlevAS/dev_odoo_base_docker&lt;/a&gt;) — builds a Docker image per Odoo version (for example, 17.0) with all system dependencies, Python, wkhtmltopdf, Node LTS, postgres client, and a non-root user, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevContainer template repository&lt;/strong&gt; (&lt;a href="//github.com/BorovlevAS/odoo_devcontainer_template"&gt;github.com/BorovlevAS/odoo_devcontainer_template&lt;/a&gt;) — a project skeleton where everything lives under &lt;code&gt;/workspace&lt;/code&gt;: Odoo sources, extra addons, configs, scripts, and the &lt;code&gt;.devcontainer&lt;/code&gt; folder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The environment runs inside &lt;strong&gt;Visual Studio Code&lt;/strong&gt; using the official &lt;strong&gt;Dev Containers&lt;/strong&gt; extension — it automatically builds the defined container, mounts the workspace, and connects your VS Code session into it.&lt;/p&gt;

&lt;p&gt;One command, and the developer is ready to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Principle:&lt;/strong&gt; build heavy once → reuse across many projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn’t use the official Odoo image
&lt;/h2&gt;

&lt;p&gt;The official &lt;code&gt;odoo/docker&lt;/code&gt; image is great for running containers in production, but it’s not ideal for day-to-day development. It hides too much under the hood — system libraries, Python environment, and even where Odoo itself is installed. When you need to debug or patch something, you quickly hit invisible walls.&lt;/p&gt;

&lt;p&gt;So I built my own base image optimized for development. Here’s what it gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full control over environment&lt;/strong&gt; — I choose the OS (Ubuntu/Debian), Python and Node versions, PostgreSQL client, wkhtmltopdf, and all required system libraries. No “magic” from upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified dev experience&lt;/strong&gt; — developer tools are pre-installed: &lt;code&gt;bash-completion&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;pre-commit&lt;/code&gt;, &lt;code&gt;nodejs&lt;/code&gt;, and &lt;code&gt;linters&lt;/code&gt;. No more “just install this manually”.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct Odoo source debugging&lt;/strong&gt; — the full Odoo source is mounted inside the container, so I can browse, patch, or set breakpoints directly in VS Code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-version setup&lt;/strong&gt; — versions 14/15/16/17/18 share the same structure and principles; only the base tag changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed &amp;amp; caching&lt;/strong&gt; — each image contains a pre-built virtualenv and cached Python wheels, so Dev Containers start up fast without re-installing gigabytes of dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper permissions&lt;/strong&gt; — runs as a non-root user with correct UID/GID for VS Code volumes. No permission surprises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictable folder structure&lt;/strong&gt; — &lt;code&gt;/workspace&lt;/code&gt;, &lt;code&gt;/opt/odoo/venv&lt;/code&gt;, and &lt;code&gt;/usr/local/lib/node_modules/&lt;/code&gt; always follow the same layout, which makes it easy to pre-configure VS Code and extensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Base image: decisions &amp;amp; ingredients (Odoo 18 as an example)
&lt;/h2&gt;

&lt;p&gt;The base image represents the infrastructure layer of the development environment.&lt;/p&gt;

&lt;p&gt;Its responsibility is limited and explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;provide a stable OS base&lt;/li&gt;
&lt;li&gt;install system-level dependencies&lt;/li&gt;
&lt;li&gt;prepare a Python runtime with an isolated virtual environment&lt;/li&gt;
&lt;li&gt;establish a predictable filesystem layout for mounted projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; contain application code or business logic. Projects consume this image; they do not shape it.&lt;/p&gt;

&lt;h3&gt;
  
  
  OS base
&lt;/h3&gt;

&lt;p&gt;For the current setup, the image is built on top of &lt;code&gt;ubuntu:noble&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A full Ubuntu base simplifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;installing and maintaining system libraries&lt;/li&gt;
&lt;li&gt;working with .deb packages&lt;/li&gt;
&lt;li&gt;keeping the environment close to common Linux setups used by development teams.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Image size is not the primary concern here — stability and predictability are.&lt;/p&gt;

&lt;h3&gt;
  
  
  System-level dependencies
&lt;/h3&gt;

&lt;p&gt;The image installs a fixed set of system packages that rarely change between projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compiler toolchain and build essentials&lt;/li&gt;
&lt;li&gt;common runtime libraries&lt;/li&gt;
&lt;li&gt;fonts and rendering-related packages&lt;/li&gt;
&lt;li&gt;client tools for external services (e.g. databases)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Installing these dependencies once in the base image avoids repeating the same setup across multiple projects and repositories.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python runtime and virtual environment
&lt;/h3&gt;

&lt;p&gt;Python is treated as a runtime platform, not as a project-specific concern.&lt;/p&gt;

&lt;p&gt;The image creates a dedicated virtual environment located at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/opt/odoo/venv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The virtual environment is added to &lt;code&gt;PATH&lt;/code&gt;, so all Python tools inside the container use it by default.&lt;/p&gt;

&lt;p&gt;At this layer, the image installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;base Python tooling (pip, setuptools, wheel)&lt;/li&gt;
&lt;li&gt;development utilities (debugpy, pre-commit)&lt;/li&gt;
&lt;li&gt;Python dependencies required by the application framework, pinned to a specific major version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Application source code itself is not part of the image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Developer tooling (Node.js)
&lt;/h3&gt;

&lt;p&gt;Node.js is included solely to support developer tooling.&lt;/p&gt;

&lt;p&gt;In practice, it is used to run formatting and automation tools such as Prettier and its plugins.&lt;/p&gt;

&lt;p&gt;It is not part of the runtime stack and is not involved in application execution.&lt;/p&gt;

&lt;p&gt;By placing these tools in the base image, formatting behavior becomes consistent across all environments without requiring per-project setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  User and permissions model
&lt;/h3&gt;

&lt;p&gt;The image ensures the presence of a non-root user with UID/GID 1000, matching typical host user IDs.&lt;/p&gt;

&lt;p&gt;This avoids permission issues when project files are mounted into the container and allows developers to work without elevated privileges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filesystem conventions
&lt;/h3&gt;

&lt;p&gt;The image establishes a convention that all project files are mounted under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/workspace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base image does not populate this directory. It only guarantees that the environment expects application code, configuration, and scripts to appear there at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this layer intentionally excludes
&lt;/h3&gt;

&lt;p&gt;The infrastructure image deliberately does not include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;application source code&lt;/li&gt;
&lt;li&gt;custom extensions or plugins&lt;/li&gt;
&lt;li&gt;environment-specific configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation keeps the base image reusable and stable, while allowing projects to evolve independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unified &lt;code&gt;/workspace&lt;/code&gt; structure
&lt;/h2&gt;

&lt;p&gt;On top of the base image, every project follows the same filesystem convention: &lt;strong&gt;all project-related files are mounted under a single directory — &lt;code&gt;/workspace&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is not an implementation detail; it is a design decision.&lt;/p&gt;

&lt;p&gt;From the editor’s point of view, /workspace is the project. There are no secondary mounts, hidden paths, or split workspaces.&lt;/p&gt;

&lt;p&gt;A typical layout looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/workspace
 ├── .devcontainer/
 ├── conf/
 ├── docker/
 ├── extra_addons/
 ├── odoo/
 └── scripts/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One workspace, one mental model
&lt;/h3&gt;

&lt;p&gt;Using a single top-level directory simplifies several things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the editor works with a single workspace root&lt;/li&gt;
&lt;li&gt;relative paths in configuration files remain stable&lt;/li&gt;
&lt;li&gt;debugging and navigation behave predictably.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no need to remember where the core framework lives, where custom code is mounted, or how paths are stitched together inside the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clear separation of concerns
&lt;/h3&gt;

&lt;p&gt;Each directory under &lt;code&gt;/workspace&lt;/code&gt; has a narrow, explicit role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.devcontainer/&lt;/code&gt;
Dev Container configuration: how the environment is started and integrated with VS Code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;conf/&lt;/code&gt;
Runtime configuration files (application config, database config, tooling config).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker/&lt;/code&gt;
Docker Compose files and optional project-level Docker overrides.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extra_addons/&lt;/code&gt;
Project-specific extensions and custom modules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;odoo/&lt;/code&gt;
Application framework source code, tracked as a Git submodule.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/&lt;/code&gt;
Helper scripts for common development tasks (initialization, updates, tests).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This structure keeps infrastructure, framework, and project code clearly separated, while still being visible in a single place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the framework lives inside the workspace
&lt;/h3&gt;

&lt;p&gt;Keeping the framework source code inside &lt;code&gt;/workspace&lt;/code&gt; is intentional.&lt;/p&gt;

&lt;p&gt;It allows developers to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inspect and debug framework code&lt;/li&gt;
&lt;li&gt;step through execution without jumping between unrelated paths&lt;/li&gt;
&lt;li&gt;apply temporary patches when needed during development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the same time, the framework remains isolated from the base image and can be versioned, updated, or replaced at the project level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration stays explicit
&lt;/h3&gt;

&lt;p&gt;Configuration files are part of the project repository and live under &lt;code&gt;conf/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing is hidden inside the image, and no configuration is generated implicitly. What the application runs with is always visible and version-controlled.&lt;/p&gt;

&lt;p&gt;This makes environments easier to reason about and easier to reproduce.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters in practice
&lt;/h3&gt;

&lt;p&gt;A unified &lt;code&gt;/workspace&lt;/code&gt; layout reduces friction in daily work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fewer assumptions about paths&lt;/li&gt;
&lt;li&gt;fewer “where does this file live?” moments&lt;/li&gt;
&lt;li&gt;less editor and debugger configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Developers can focus on code and behavior, not on reconstructing the environment in their heads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev Containers as the entry point
&lt;/h2&gt;

&lt;p&gt;The Dev Container is the entry point into the development environment.&lt;/p&gt;

&lt;p&gt;It connects the infrastructure layer (base image + Docker Compose) with the developer’s editor and defines how the project is opened, initialized, and used on a daily basis.&lt;/p&gt;

&lt;p&gt;The configuration lives in &lt;code&gt;.devcontainer/devcontainer.json&lt;/code&gt; and describes three key things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how containers are started&lt;/li&gt;
&lt;li&gt;which service the editor attaches to&lt;/li&gt;
&lt;li&gt;what should happen when the environment is created.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Docker Compose as the runtime definition
&lt;/h3&gt;

&lt;p&gt;Instead of defining a single container, the Dev Container setup relies on Docker Compose.&lt;/p&gt;

&lt;p&gt;This allows the development environment to be described as a small system rather than a standalone container:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an application container&lt;/li&gt;
&lt;li&gt;a database container&lt;/li&gt;
&lt;li&gt;optional supporting services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Dev Container configuration references existing Compose files rather than replacing them.&lt;br&gt;
This keeps the runtime definition declarative and reusable outside of VS Code if needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attaching the editor to a running service
&lt;/h3&gt;

&lt;p&gt;The editor attaches directly to the application service defined in Docker Compose.&lt;/p&gt;

&lt;p&gt;From the editor’s perspective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/workspace&lt;/code&gt; becomes the workspace root&lt;/li&gt;
&lt;li&gt;the container user is a non-root developer user&lt;/li&gt;
&lt;li&gt;all tools run inside the container context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no special “editor container” and no duplicated environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment initialization
&lt;/h3&gt;

&lt;p&gt;When the container is created for the first time, a project-specific initialization script is executed.&lt;/p&gt;

&lt;p&gt;Typical responsibilities at this stage include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generating local environment files&lt;/li&gt;
&lt;li&gt;preparing configuration defaults&lt;/li&gt;
&lt;li&gt;performing lightweight sanity checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This step is intentionally kept explicit and script-driven.&lt;/p&gt;

&lt;p&gt;Nothing happens implicitly, and nothing is hidden inside the image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Editor integration
&lt;/h3&gt;

&lt;p&gt;The Dev Container configuration also defines the editor-side environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;required extensions&lt;/li&gt;
&lt;li&gt;interpreter paths&lt;/li&gt;
&lt;li&gt;formatting and tooling settings&lt;/li&gt;
&lt;li&gt;port forwarding for local access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These settings are part of the repository and shared by the team.&lt;/p&gt;

&lt;p&gt;As a result, every developer opens the project with the same editor configuration and the same tooling behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a developer actually does
&lt;/h3&gt;

&lt;p&gt;From a developer’s point of view, the workflow is reduced to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repository&lt;/li&gt;
&lt;li&gt;Open it in the editor&lt;/li&gt;
&lt;li&gt;Reopen in container&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point, the environment is fully operational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;containers are running&lt;/li&gt;
&lt;li&gt;the workspace is mounted&lt;/li&gt;
&lt;li&gt;tools are available&lt;/li&gt;
&lt;li&gt;configuration is in place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Dev Container does not add new concepts; it formalizes and automates existing ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day-to-day developer workflow
&lt;/h2&gt;

&lt;p&gt;Once the environment is up and running, daily development happens entirely inside the container.&lt;/p&gt;

&lt;p&gt;There is no distinction between “local” and “container” workflows — the container is the development environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running the application
&lt;/h3&gt;

&lt;p&gt;The application is started directly from the editor using a predefined launch configuration.&lt;/p&gt;

&lt;p&gt;This allows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;running the server in a controlled way&lt;/li&gt;
&lt;li&gt;attaching the debugger immediately&lt;/li&gt;
&lt;li&gt;restarting the process without rebuilding containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the developer’s perspective, this feels no different from working in a local virtual environment — except the environment is fully reproducible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Working with code
&lt;/h3&gt;

&lt;p&gt;All code lives under &lt;code&gt;/workspace&lt;/code&gt; and is immediately visible to the editor.&lt;/p&gt;

&lt;p&gt;Typical tasks include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;implementing features in &lt;code&gt;extra_addons/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;inspecting or temporarily adjusting framework code under &lt;code&gt;odoo/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;navigating configuration in &lt;code&gt;conf/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no need to switch contexts or open multiple folders.&lt;br&gt;
Everything relevant to development is available in a single workspace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updating the database and modules
&lt;/h3&gt;

&lt;p&gt;Common repetitive tasks are handled through project scripts.&lt;/p&gt;

&lt;p&gt;Updating modules or applying changes to the database is done via explicit commands rather than ad-hoc shell invocations. This keeps workflows consistent and reduces accidental mistakes.&lt;/p&gt;

&lt;p&gt;Scripts are version-controlled alongside the project, making behavior transparent and reviewable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging
&lt;/h3&gt;

&lt;p&gt;Debugging is one of the main reasons for keeping everything inside the same workspace.&lt;/p&gt;

&lt;p&gt;Breakpoints can be set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in project code&lt;/li&gt;
&lt;li&gt;in framework code&lt;/li&gt;
&lt;li&gt;across multiple modules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the editor, runtime, and source code all live in the same environment, stepping through execution is predictable and stable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running tests
&lt;/h3&gt;

&lt;p&gt;Tests are executed inside the same container and against the same environment used for development.&lt;/p&gt;

&lt;p&gt;This avoids the common situation where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tests pass locally but fail elsewhere&lt;/li&gt;
&lt;li&gt;behavior differs due to mismatched dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  No hidden state
&lt;/h3&gt;

&lt;p&gt;All state that matters is either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in version-controlled files&lt;/li&gt;
&lt;li&gt;or in explicitly managed volumes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restarting containers does not break the workflow, and rebuilding the environment does not require manual reconfiguration.&lt;/p&gt;

&lt;p&gt;The environment is designed to be disposable, not fragile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and scope
&lt;/h2&gt;

&lt;p&gt;This setup is intentionally opinionated.&lt;/p&gt;

&lt;p&gt;It optimizes for &lt;strong&gt;development experience, reproducibility, and onboarding speed&lt;/strong&gt;, not for minimalism or production deployment.&lt;/p&gt;

&lt;p&gt;A few important boundaries to be aware of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This environment is meant for &lt;strong&gt;development&lt;/strong&gt;, not production. Security hardening, resource tuning, and deployment concerns are deliberately out of scope.&lt;/li&gt;
&lt;li&gt;Docker Compose is used as a pragmatic runtime definition.This is not a Kubernetes-first setup and does not try to simulate production orchestration.&lt;/li&gt;
&lt;li&gt;Images are not minimal. Stability, debuggability, and predictable tooling take priority over image size.&lt;/li&gt;
&lt;li&gt;The approach assumes a long-lived codebase. For quick experiments or throwaway prototypes, this structure may be unnecessarily heavy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These trade-offs are explicit.&lt;br&gt;
The goal is not to cover every possible scenario, but to provide a reliable default for teams working on real projects over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This setup emerged from a practical need: working on multiple projects, across multiple Odoo versions, without constantly rebuilding development environments from scratch.&lt;/p&gt;

&lt;p&gt;By separating concerns clearly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a reusable infrastructure image&lt;/li&gt;
&lt;li&gt;a consistent project layout under /workspace&lt;/li&gt;
&lt;li&gt;and Dev Containers as the entry point&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the development environment becomes predictable and disposable rather than fragile and stateful.&lt;/p&gt;

&lt;p&gt;The biggest gain is not Docker itself, but consistency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;consistent tooling&lt;/li&gt;
&lt;li&gt;consistent paths&lt;/li&gt;
&lt;li&gt;consistent workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once established, the environment fades into the background and lets developers focus on code instead of setup.&lt;/p&gt;

&lt;p&gt;Both repositories are intentionally simple and open to adaptation.&lt;br&gt;
If this approach resonates with your workflow, feel free to reuse it, adjust it, or build your own variant on top of the same principles.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>vscode</category>
      <category>odoo</category>
    </item>
  </channel>
</rss>
