<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>devfrs — Engineering notes</title><description>What I learned building — measured, not guessed.</description><link>https://devfrs.com/en/blog/</link><language>en</language><atom:link href="https://devfrs.com/en/rss.xml" rel="self" type="application/rss+xml"/><item><title>What I build, and why I write about it</title><link>https://devfrs.com/en/blog/what-i-build-and-why-i-write/</link><guid isPermaLink="true">https://devfrs.com/en/blog/what-i-build-and-why-i-write/</guid><description>This blog exists because I forget the why behind decisions — the code stays, the reasoning evaporates. A map of what I do at work, what I build outside it, and the stack choices I would defend in a review.</description><pubDate>Fri, 31 Jul 2026 13:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This blog exists for a practical reason: I forget.&lt;/p&gt;
&lt;p&gt;Not what I did — the &lt;strong&gt;why&lt;/strong&gt;. Six months after an architectural decision the code is still there, but the reasoning has evaporated. You are left with consequences and no argument, and nobody can review a decision they cannot reconstruct. Writing is the cheapest way I have found to keep the argument next to the result.&lt;/p&gt;
&lt;p&gt;The second reason is less noble and just as true: explaining something in writing shows immediately whether I understood it. Almost every piece I started here changed its conclusion halfway through, because the version in my head did not survive being said out loud.&lt;/p&gt;
&lt;h2 id=&quot;where-i-work&quot;&gt;Where I work&lt;/h2&gt;
&lt;p&gt;I have been a developer at Grupo Coagro since July 2024. The work there is extending an ERP that remains the ERP: mapping where its rules end and building only the edge where the business asks for a rule it does not have. A field CRM, agronomic prescription issuance, single sign-on. I wrote about it in &lt;a href=&quot;https://devfrs.com/en/blog/building-around-winthor/&quot;&gt;Where the ERP stops&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Outside working hours I take freelance web and mobile projects. It is not a double life: it is the same problem at different scales — someone needs software that works, and someone accountable for it.&lt;/p&gt;
&lt;h2 id=&quot;the-stack-and-why-it-is-this-one&quot;&gt;The stack, and why it is this one&lt;/h2&gt;
&lt;p&gt;A list of technologies says nothing about anyone. What says something is the reason behind each choice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flutter&lt;/strong&gt; is where I spend most of my time. The reason is not the language: a small team cannot sustain two native codebases, and the honest alternative to cross-platform is usually &lt;em&gt;serving one platform well and the other badly&lt;/em&gt;. When the app runs in the hands of someone out in the field on a bad connection, what matters is that it exists in both places and answers quickly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;React&lt;/strong&gt; and &lt;strong&gt;Electron&lt;/strong&gt; for what lives in the browser and on the desktop. Electron has a deserved reputation for weight, and it is still the right answer when the requirement is filesystem access behind the same interface as the web.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; for the modelling, not the shortcut. What it solves well is giving you real Postgres with RLS in front — access rules that live in the database, not in the application. Access rules in application code are rules somebody forgets to apply on some path.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt; and &lt;strong&gt;Kubernetes&lt;/strong&gt; for what has to come up identically on any machine. The interesting part is not the container: it is that “works on my machine” stops being a sentence anyone can say.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CI/CD&lt;/strong&gt; with GitHub Actions — and the habit that changed my days the most: running the pipeline &lt;strong&gt;locally, in the terminal&lt;/strong&gt;, before pushing. A pipeline that only runs on the server turns every lint error into a commit-push-wait cycle. Run it first and the error costs seconds and never becomes noise in the history.&lt;/p&gt;
&lt;h2 id=&quot;what-i-build-outside-work&quot;&gt;What I build outside work&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A gym app.&lt;/strong&gt; The visible part is logging workouts; the hard part is the modelling. Load, set, rep and progression look like four fields until you try to answer “is this person improving?” — then they become history, comparison across periods, and the difference between changing an exercise and changing a stimulus. It is the kind of problem where getting the schema wrong early buys you an ugly migration later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;API integrations&lt;/strong&gt; with large platforms such as Dropbox and Google Maps. Integration is always the same lesson in new clothes: the contract belongs to someone else, it changes without notice, and network failure is not an exception — it is a normal state the code has to handle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SaaS and AI-powered tools.&lt;/strong&gt; Still exploratory, and the question I carry is where the model genuinely adds value and where it only adds a source of plausible error. A plausible wrong answer is the most expensive defect there is, because nobody double-checks what did not look odd.&lt;/p&gt;
&lt;h2 id=&quot;what-is-coming-here&quot;&gt;What is coming here&lt;/h2&gt;
&lt;p&gt;Architectural decisions with the argument attached. Mistakes that cost me time, told with the number that made me notice. Solutions I went looking for and could not find written down anywhere.&lt;/p&gt;
&lt;p&gt;I do not plan to write tutorials for things that already have good documentation. I plan to write what I would have wanted to find at two in the morning.&lt;/p&gt;</content:encoded><category>Carreira</category><category>Flutter</category><category>Supabase</category><category>DevOps</category><category>Arquitetura</category></item><item><title>Where the ERP stops: extending Winthor at Coagro</title><link>https://devfrs.com/en/blog/building-around-winthor/</link><guid isPermaLink="true">https://devfrs.com/en/blog/building-around-winthor/</guid><description>Winthor is the ERP and will remain so. The question worth asking is not replace-or-keep — it is where its rules end and the business&apos;s rules carry on. Three gaps that became tools: a field CRM, agronomic prescriptions and single sign-on.</description><pubDate>Fri, 31 Jul 2026 11:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/blog/coagro.png&quot; alt=&quot;Grupo Coagro logo on a black background with a technical grid&quot; /&gt;&lt;/p&gt;&lt;p&gt;Winthor is Grupo Coagro’s ERP and it is going to stay that way. Inventory, invoicing, price tables, customer records, receivables: it is all there, it works, and nobody on the team wants to touch it.&lt;/p&gt;
&lt;p&gt;That is why the interesting question was never “replace or keep”. It is a narrower and far more useful one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Where do the ERP’s rules end and the business’s rules carry on?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A distribution ERP is designed for the &lt;strong&gt;general case&lt;/strong&gt; — what holds equally for someone selling bolts, medicine or crop protection. That is what makes it dependable across twenty years, and it is exactly what leaves edges: the part of the business that is not “generic distribution” has no reason to live inside it.&lt;/p&gt;
&lt;p&gt;The work lives on those edges. Not replacing what Winthor does — &lt;strong&gt;adding the rule it would have no reason to carry.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;three-places-where-the-edge-shows&quot;&gt;Three places where the edge shows&lt;/h2&gt;
&lt;h3 id=&quot;the-order-exists-the-conversation-leading-to-it-does-not&quot;&gt;The order exists. The conversation leading to it does not.&lt;/h3&gt;
&lt;p&gt;The ERP records orders. But an order is the &lt;strong&gt;end&lt;/strong&gt; of a sequence: a farm visit, a crop diagnosis, a recommendation, a quote, negotiation, follow-up. None of that is a sale yet, which is why none of it fit where sales live.&lt;/p&gt;
&lt;p&gt;With nowhere to go, that sequence stayed in the salesperson’s head and notebook. It works — while they are there. The day they go on holiday, the account goes with them.&lt;/p&gt;
&lt;p&gt;The CRM covers that stretch, and only that. It reads from Winthor what is already true (customer, purchase history, credit limit, available stock) and holds what has not become an order yet. When it does, the order is born in the ERP, where it always was.&lt;/p&gt;
&lt;p&gt;Built in Flutter because an input salesperson works on the farm, not at a desk. A CRM that only opens on desktop becomes a nightly, from-memory exercise — when it gets filled in at all.&lt;/p&gt;
&lt;h3 id=&quot;an-agronomic-prescription-is-a-legal-document-not-a-form&quot;&gt;An agronomic prescription is a legal document, not a form&lt;/h3&gt;
&lt;p&gt;Issuing an agronomic prescription is an act carrying technical liability: product, crop, target pest, dose, area, responsible agronomist and signature, within what the law allows for that combination. It is not a text field — it is a rule that &lt;strong&gt;validates&lt;/strong&gt; before it prints.&lt;/p&gt;
&lt;p&gt;That is precisely the kind of rule a distribution ERP has no reason to carry: it does not apply to someone selling bolts. It applies to us, and getting it wrong is not rework, it is regulatory risk.&lt;/p&gt;
&lt;p&gt;The tool pulls from the ERP what already exists — product, customer, what was purchased — and applies on top the rule the ERP does not know. The agronomist stops typing what the system already knows and goes back to deciding what only they can decide.&lt;/p&gt;
&lt;h3 id=&quot;remembering-every-system-is-not-access-control&quot;&gt;“Remembering every system” is not access control&lt;/h3&gt;
&lt;p&gt;Every new tool arrives with a tedious question: who gets in? Solved tool by tool, the answer becomes one login per system — and offboarding someone starts to depend on &lt;strong&gt;remembering&lt;/strong&gt; all of them.&lt;/p&gt;
&lt;p&gt;Remembering is not a control. It is an intention.&lt;/p&gt;
&lt;p&gt;Centralising access was not user convenience. It is the difference between revoking access and hoping you revoked it — and in a company handling credit and customer data, that difference has a name: audit.&lt;/p&gt;
&lt;h2 id=&quot;the-principle-that-held-it-together&quot;&gt;The principle that held it together&lt;/h2&gt;
&lt;p&gt;Looking at the three cases side by side, the pattern is the same:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The data belongs to the ERP. The new rule is ours. And the rule never copies the data.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If the information already has an owner in Winthor, it is queried, not replicated. Duplicating is faster to write and cheaper to read — which is exactly why it is tempting. The bill arrives later, the day the two copies disagree and nobody knows which one is right. Nobody wants to find that out from the customer.&lt;/p&gt;
&lt;p&gt;What Supabase holds is only what was &lt;strong&gt;born&lt;/strong&gt; with the tools and does not exist in the ERP: session, permission, draft visit, the note that has not become an order yet.&lt;/p&gt;
&lt;h2 id=&quot;what-is-still-hard&quot;&gt;What is still hard&lt;/h2&gt;
&lt;p&gt;Reading from a database that is not yours means treating its schema as an API you do not control — no versioning, no changelog, no coordinated deploy. A column that changes meaning does not announce it; it simply starts returning something else.&lt;/p&gt;
&lt;p&gt;The defence is the usual one, and it is not sophisticated: &lt;strong&gt;fail loud&lt;/strong&gt;. An integration that breaks quietly hands back a plausible number, and a plausible wrong number is the most expensive defect there is — because nobody goes looking for what did not raise an error.&lt;/p&gt;
&lt;h2 id=&quot;what-i-take-from-this&quot;&gt;What I take from this&lt;/h2&gt;
&lt;p&gt;“Modernising the ERP” almost never means replacing the ERP. It means mapping precisely where it is right — usually most of it — and building only on the edge where the business asks for a rule it does not have.&lt;/p&gt;
&lt;p&gt;Winthor is still the source of truth. What changed is that it no longer has to answer, alone, questions that were never its own.&lt;/p&gt;</content:encoded><category>Agro</category><category>Winthor</category><category>ERP</category><category>Flutter</category><category>Arquitetura</category></item><item><title>Lighthouse was measuring the wrong page</title><link>https://devfrs.com/en/blog/lighthouse-was-measuring-the-wrong-page/</link><guid isPermaLink="true">https://devfrs.com/en/blog/lighthouse-was-measuring-the-wrong-page/</guid><description>A performance gate that passes is worthless if it measures something else. How a busy port handed me a plausible, wrong perf 62 — and what I changed so the error can never fail silently again.</description><pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was hardening the Lighthouse gate on devfrs.com: move from &lt;code&gt;warn&lt;/code&gt; to &lt;code&gt;error&lt;/code&gt;, with SEO at 100. The first result came back odd.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;route&lt;/th&gt;&lt;th&gt;perf&lt;/th&gt;&lt;th&gt;seo&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;&lt;td&gt;75&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/en/&lt;/code&gt;&lt;/td&gt;&lt;td&gt;38&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/projeto/&amp;#x3C;slug&gt;/&lt;/code&gt;&lt;/td&gt;&lt;td&gt;79&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Thirty-eight. On near-identical pages, from the same build. And SEO — the thing I was actually changing — had hit 100, so the temptation was to celebrate and write performance off as noise.&lt;/p&gt;
&lt;h2 id=&quot;the-symptom-that-didnt-add-up&quot;&gt;The symptom that didn’t add up&lt;/h2&gt;
&lt;p&gt;Alongside the scores came a &lt;code&gt;link-text&lt;/code&gt; failure: a link reading “Learn more” pointing at &lt;code&gt;docs.astro.build&lt;/code&gt;. I have no such link anywhere. I searched the whole &lt;code&gt;dist/&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-dark-default github-light-default&quot; style=&quot;background-color:#0d1117;--shiki-light-bg:#ffffff;color:#e6edf3;--shiki-light:#1f2328; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FFA657;--shiki-light:#953800&quot;&gt;grep&lt;/span&gt;&lt;span style=&quot;color:#79C0FF;--shiki-light:#0550AE&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt; &quot;docs.astro.build&quot;&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt; dist/&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing. The link didn’t exist in what I had built — but it existed on the page Lighthouse measured. There’s only one explanation: &lt;strong&gt;the page it measured wasn’t mine.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-cause&quot;&gt;The cause&lt;/h2&gt;
&lt;p&gt;That “Learn more” belongs to the Astro dev toolbar, which &lt;strong&gt;only exists in dev mode&lt;/strong&gt;. Lighthouse had audited the dev server, not &lt;code&gt;dist/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The reason is trivial, which is exactly why it slips through: I had &lt;code&gt;npm run dev&lt;/code&gt; open on port 4321, and my test server tried to bind the same port. It failed, the process died quietly, and Lighthouse found &lt;em&gt;someone&lt;/em&gt; answering on 4321. It happily measured that.&lt;/p&gt;
&lt;p&gt;Perf 62 for a site in dev mode is a perfectly plausible number. That’s the problem. An error that screams is cheap; an error that hands you a &lt;strong&gt;believable&lt;/strong&gt; number costs hours.&lt;/p&gt;
&lt;h2 id=&quot;the-two-fixes&quot;&gt;The two fixes&lt;/h2&gt;
&lt;p&gt;The first is obvious: a separate port for the gate. The second is the one that matters:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-dark-default github-light-default&quot; style=&quot;background-color:#0d1117;--shiki-light-bg:#ffffff;color:#e6edf3;--shiki-light:#1f2328; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;server.&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF;--shiki-light:#8250DF&quot;&gt;on&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt;&quot;error&quot;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;, (&lt;/span&gt;&lt;span style=&quot;color:#FFA657;--shiki-light:#953800&quot;&gt;err&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#FF7B72;--shiki-light:#CF222E&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FF7B72;--shiki-light:#CF222E&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt; (err.code &lt;/span&gt;&lt;span style=&quot;color:#FF7B72;--shiki-light:#CF222E&quot;&gt;===&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt; &quot;EADDRINUSE&quot;&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;    console.&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF;--shiki-light:#8250DF&quot;&gt;error&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt;      `[serve-dist] port ${&lt;/span&gt;&lt;span style=&quot;color:#79C0FF;--shiki-light:#0550AE&quot;&gt;PORT&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt;} is already in use — aborting.&lt;/span&gt;&lt;span style=&quot;color:#FF7B72;--shiki-light:#CF222E&quot;&gt;\n&lt;/span&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#FF7B72;--shiki-light:#CF222E&quot;&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A5D6FF;--shiki-light:#0A3069&quot;&gt;        `Another process would answer instead of dist/, and Lighthouse would measure the wrong page.`&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;    );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;  process.&lt;/span&gt;&lt;span style=&quot;color:#D2A8FF;--shiki-light:#8250DF&quot;&gt;exit&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#79C0FF;--shiki-light:#0550AE&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E6EDF3;--shiki-light:#1F2328&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fail loud instead of quiet. If the port is taken, nothing gets measured — better to break the gate with a clear message than to publish a made-up number.&lt;/p&gt;
&lt;p&gt;With the right server and a median of three runs, the numbers became 95 and 99. Only then was it worth locking the gate down.&lt;/p&gt;
&lt;h2 id=&quot;what-i-take-from-this&quot;&gt;What I take from this&lt;/h2&gt;
&lt;p&gt;Before trusting a measurement, it’s worth asking what it would report if it were broken. If the answer is “a number close to the right one”, you’re missing a signal — not precision.&lt;/p&gt;</content:encoded><category>Performance</category><category>Lighthouse</category><category>CI</category></item></channel></rss>