<?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>DEV Community: Anatolii</title>
    <description>The latest articles on DEV Community by Anatolii (@econ__11).</description>
    <link>https://dev.to/econ__11</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3997488%2Fdd3aa5cd-efc2-40fd-b347-935f3a853b05.jpg</url>
      <title>DEV Community: Anatolii</title>
      <link>https://dev.to/econ__11</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/econ__11"/>
    <language>en</language>
    <item>
      <title>Interviewing Go developers: stop caring about, and weight heavily</title>
      <dc:creator>Anatolii</dc:creator>
      <pubDate>Mon, 29 Jun 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/econ__11/interviewing-go-developers-stop-caring-about-and-weight-heavily-4mff</link>
      <guid>https://dev.to/econ__11/interviewing-go-developers-stop-caring-about-and-weight-heavily-4mff</guid>
      <description>&lt;p&gt;I run technical screenings for Go developers. Someone comes in, we talk, I dig into what they know and how they think, and afterward I write up a recommendation with my own conclusions: pass this person to the next stage, or stop here. I don't make the final hire call. But at the technical screen I'm the gate, and what I weight in that hour decides who gets through.&lt;/p&gt;

&lt;p&gt;What I weight now is not what I'd have weighted a few years ago. The strange part is that most of what changed didn't come from interviewing at all. It came from before that, from spending years on a small team, working next to engineers day after day, and watching who actually turned out to be strong. You learn a lot about what makes a good engineer when you're not asking them questions across a table but shipping alongside them for two years. By the time I started interviewing, I already had a fairly clear picture of what good looks like over the long run. Interviewing just became the place I try to detect it in one conversation.&lt;/p&gt;

&lt;p&gt;So this is the gap between the two. The signals I used to trust and have mostly stopped caring about, and the ones I learned to weight heavily, mostly from watching real engineers play out over time rather than from any interview.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I stopped caring about?
&lt;/h2&gt;




&lt;h4&gt;
  
  
  Answers that sound memorized
&lt;/h4&gt;

&lt;p&gt;There was a time when a clean, confident, textbook-perfect answer read to me as "this person knows the material." Now it reads as almost nothing. At best it's a checkbox: okay, they've heard the question before, they have the standard answer ready.&lt;/p&gt;

&lt;p&gt;The problem is that a polished answer to a common question tells you the person prepared for common questions. That's it. Plenty of people walk into an interview having rehearsed the usual Go rounds, goroutines, channels, interfaces, the difference between a slice and an array, and they can recite all of it. It sounds like knowledge. It often isn't.&lt;/p&gt;

&lt;p&gt;So when an answer comes out sounding rehearsed, that's not where I stop. That's where I start. I begin digging into depth and into breadth. I take the thing they just said fluently and I push on it: okay, why, and what happens if, and when would you not do that. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A surprising number of candidates crumble right there. The recited answer was the whole inventory, and one layer underneath it there's nothing. A memorized answer isn't a bad sign by itself. But I've stopped treating it as a good one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Years on the resume
&lt;/h4&gt;

&lt;p&gt;I used to read years of experience as a proxy for seniority. I almost ignore them now, within reason.&lt;/p&gt;

&lt;p&gt;There's a floor, obviously. For example, you're not going to be senior with about a year of total programming experience behind you, there hasn't been a plenty time to see enough go wrong. But above that floor, the number stops telling me much. The case that made this click for me was someone who was genuinely senior in another language and moved to Go maybe a year ago. The Go line on their resume says "1 year." Their actual engineering seniority is not one year old. The judgment, the instinct for what will hurt later, the ability to reason about a system, that came with them. Counting their Go months and concluding "junior" would just be wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I came to Go from years of PHP myself, so maybe I'm biased here. But I've seen enough strong engineers cross into Go to stop letting the language tenure on the page stand in for how good they actually are.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Raw speed of answering
&lt;/h4&gt;

&lt;p&gt;This one I didn't drop so much as learn to read properly, because speed isn't one signal. It's at least three, and they point in different directions.&lt;/p&gt;

&lt;p&gt;A fast answer can be great. It can also just be someone generating options without thinking. That's not a bad trait on its own, fast idea generation is useful, but if that's all it is, the person needs to learn to validate their own options before committing to one. So a quick answer makes me want to check: did you actually weigh that, or did it just fall out?&lt;/p&gt;

&lt;p&gt;A slow answer with visible reasoning is usually a good sign, or at worst a neutral one. When someone takes their time and I can watch the logic move, they're considering it, ruling things out, working toward it, that slowness very often just means they haven't hit this exact problem before. That's experience, not ability. Experience is teachable. I'm not going to penalize someone for thinking in front of me.&lt;/p&gt;

&lt;p&gt;The only version of slow that's a clear bad sign is slow plus nonsense, when the person takes a long time and what comes out is just wrong, with no thread of reasoning holding it together. That one's unambiguous. But plain slowness? I stopped treating that as negative a long time ago.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I weight heavily now
&lt;/h2&gt;




&lt;h4&gt;
  
  
  Depth and breadth, not recall
&lt;/h4&gt;

&lt;p&gt;The thing I'm actually trying to measure is how deep and how broad the real understanding goes, not how much got memorized. Those are different things and they come apart fast under pressure.&lt;/p&gt;

&lt;p&gt;The way I get at it is simple, and it's the core of how I run the screen. I latch onto one of two things. Either it's a question I asked, and I just keep going deeper on it, the next layer, and the next, and the next, until I reach the edge of what the person actually knows. Or it's a claim the candidate made themselves, something they stated as fact, and I start asking tricky follow-ups on it to see how well they understand the thing they just said. Both roads lead to the same place: the point where recall runs out and you find out whether there's real understanding underneath it or not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Everybody has an edge. I'm not trying to embarrass anyone by finding it. I'm trying to find out how far away it is, and what's between here and there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  How someone thinks when they don't know
&lt;/h4&gt;

&lt;p&gt;This is the one I care about most, and it's the hardest to fake. When I push someone past the edge of what they know, which I always do eventually, what happens next tells me more than any of the answers they got right.&lt;/p&gt;

&lt;p&gt;The good version: "I don't know this, but here's how I'd reason about it," and then they actually do, out loud, in a way that holds together even without the answer. That person I can work with. &lt;/p&gt;

&lt;p&gt;The bad version is bluffing, confidently producing something that sounds like an answer but isn't, hoping I won't notice. I always notice, and now I weight it heavily, because someone who bluffs past the edge of their knowledge in an interview will do the same thing in a code review or after an incident, when it costs a lot more.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How you behave at the boundary of your own knowledge is, to me, one of the most honest signals in the whole conversation. Everyone reaches that boundary, and what you do there tells me a lot about how you'll work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  How they handle a question that looks unreal
&lt;/h4&gt;

&lt;p&gt;I'll sometimes ask something that sounds tricky, a situation that looks simple on the surface but feels strange, almost not-real, an edge case that doesn't behave the way the textbook says. What I'm watching for isn't the answer. It's the reaction.&lt;/p&gt;

&lt;p&gt;Some people get thrown by it. Some stay calm and start adapting, turning the strange thing over, probing it, working with it instead of freezing. I call that being variative: able to handle a situation that looks ordinary but feels off, without losing the thread. I weight this heavily because it predicts something real about the job. Production throws unreal-feeling problems at you constantly, the bug that can't be happening, the state that shouldn't exist. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The person who stays adaptive in front of a weird interview question is, in my experience, the person who stays adaptive in front of a weird production one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  The candidate who looked perfect on the surface
&lt;/h4&gt;

&lt;p&gt;The clearest version of all of this was a candidate who answered the surface-level questions almost flawlessly. Genuinely clean, the kind of fluency that, early in my interviewing, would have impressed me. It was so smooth I suspected the same or similar questions had come up in his other interviews and he'd simply polished the answers.&lt;/p&gt;

&lt;p&gt;So I did what I do now: I went past the surface. I dug into one of his own answers, and I gave him a couple of simple live-coding tasks, nothing exotic. And it fell apart. He mumbled. He didn't understand what was being asked. On a small problem he reinvented the wheel, working hard to rebuild something that didn't need rebuilding. And whenever I pushed for a layer of detail under the fluent answer, he'd say he didn't know "such details."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A few years ago that perfect surface might have carried him through my screen. Now the perfect surface is exactly the thing that makes me dig, and the digging is where I actually learned who he was. The recited answers were the whole inventory. There was nothing one layer down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  How they carry themselves
&lt;/h4&gt;

&lt;p&gt;The last one is harder to put in technical terms, but I trust it. There's the candidate who comes in radiating "I know everything," and there's the candidate who seems a little unsure of themselves and then, the deeper you go, keeps turning out to actually know the thing. More often than not, the second one is the stronger signal. Certainty is easy to perform. Depth that quietly holds up under questioning is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;I moved from screening for what someone has stored to screening for how they think when the stored stuff runs out. Memorized answers, years on the page, raw speed: I demoted all of them, because none of them survived contact with the engineers I actually worked alongside for years. The ones who turned out strong weren't the ones with the cleanest recall. They were the ones with real depth, who stayed adaptive when things got strange, and who told you honestly when they'd hit the edge of what they knew.&lt;/p&gt;

&lt;p&gt;That's what I look for in an hour now. You can rehearse answers. You can pad a resume. You can answer fast. What you can't fake, once someone keeps pushing, is depth, and the whole job of a technical screen, the way I run it, is to keep pushing until I find out whether the depth is real. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Recall is cheap. Understanding isn't.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
    </item>
    <item>
      <title>Why I set `dynamic: false` on my Elasticsearch audit-log stream</title>
      <dc:creator>Anatolii</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:31:00 +0000</pubDate>
      <link>https://dev.to/econ__11/why-i-set-dynamic-false-on-my-elasticsearch-audit-log-stream-206k</link>
      <guid>https://dev.to/econ__11/why-i-set-dynamic-false-on-my-elasticsearch-audit-log-stream-206k</guid>
      <description>&lt;p&gt;Most Elasticsearch advice is about getting more out of it: better relevance, faster queries, smarter aggregations. This is about the opposite. It's a place where I deliberately told Elasticsearch to do less, and why that was the right call.&lt;/p&gt;

&lt;p&gt;👌 The short version: I built an audit-log pipeline, a record of what changed on our entities over time, and I set &lt;code&gt;dynamic: false&lt;/code&gt; on the mapping so Elasticsearch would stop trying to index every field it had never seen before. Run an append-only log of arbitrary change events through Elasticsearch with the default settings and the mapping grows without bound. A growing mapping doesn't announce itself. You find out it's a problem once it's already hurting you.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we were building
&lt;/h2&gt;

&lt;p&gt;The job was an audit log: track what changed on records over time. An entity gets created, updated, deleted, and we write an event describing that change. Which entity, what fields, the old and new values, who did it and when. It's append-only by nature. You never update an audit event, you only add more of them. And you read it far less often than you write it, mostly when someone asks "what happened to this record, and when?"&lt;/p&gt;

&lt;p&gt;That shape matters, because it's the shape that makes Elasticsearch's friendliest default turn against you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default that will quietly hurt you
&lt;/h2&gt;

&lt;p&gt;By default, Elasticsearch uses &lt;em&gt;dynamic mapping&lt;/em&gt;. A document arrives with a field Elasticsearch hasn't seen, it guesses a type, and it adds that field to the index mapping automatically. For most use cases this is genuinely great. You throw documents at it, it figures out the schema, you move on.&lt;/p&gt;

&lt;p&gt;Now think about what that means for an audit log.&lt;/p&gt;

&lt;p&gt;An audit log's payload is, by design, the union of every field that has ever changed on every entity you track. Every entity type, every column, every nested attribute. Anything that can change is something that can show up in a change event. Run that through dynamic mapping and Elasticsearch faithfully adds a field to the mapping for each new leaf it sees. The mapping stops describing one document shape and slowly accretes every shape your application has ever produced.&lt;/p&gt;

&lt;p&gt;A normal index has a roughly stable set of fields. An audit log over a real application does not. It trends toward "every field in the system, ever." So the feature that's a convenience everywhere else is, for this one data shape, a slow leak that you only opt out of if you saw it coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a mapping explosion actually costs
&lt;/h2&gt;

&lt;p&gt;"Mapping explosion" sounds dramatic, so I want to be concrete about the mechanism. The cost is real and it isn't obvious.&lt;/p&gt;

&lt;p&gt;The mapping is part of the cluster state, the metadata that lives in memory and is replicated across the cluster. It isn't free per field. As the field count climbs into the thousands, that metadata grows, cluster-state updates get heavier, and the cost is paid by the whole cluster, not just the one index. That's why Elasticsearch ships with a default limit of 1,000 fields per index (&lt;code&gt;index.mapping.total_fields.limit&lt;/code&gt;). The limit isn't arbitrary. It's a guardrail against exactly this.&lt;/p&gt;

&lt;p&gt;When you hit that limit, you don't get a gentle warning. Indexing fails and the document is rejected. For an audit log that's a nasty failure mode, because the whole point of the system is that it captures everything, and the way dynamic mapping breaks is by silently raising the field count until writes start getting rejected. You can "fix" it by raising the limit, but that's treating the symptom. You're buying time until a bigger number while making every cluster-state update heavier along the way.&lt;/p&gt;

&lt;p&gt;There's a second, subtler cost before you ever hit the limit. Every dynamically mapped field is a field Elasticsearch builds indexing structures for. You're paying, in memory and in indexing work, to make fields queryable that, for an audit log, almost nobody will ever query directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: &lt;code&gt;dynamic: false&lt;/code&gt; 🥳
&lt;/h2&gt;

&lt;p&gt;So I locked the mapping down. The setting that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mappings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dynamic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"entity_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"entity_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"dynamic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the same &lt;code&gt;dynamic: false&lt;/code&gt; on the nested &lt;code&gt;author&lt;/code&gt; object. The author of a change is a small, known set of fields, id, username, source, name, so I map those and lock that sub-object down too. Nothing unexpected creeps in there either.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what &lt;code&gt;dynamic: false&lt;/code&gt; does, because there are three options and they aren't the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dynamic: true&lt;/code&gt; (the default): unknown fields get auto-mapped and become searchable. This is the explosion path.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dynamic: false&lt;/code&gt;: unknown fields are stored in &lt;code&gt;_source&lt;/code&gt; and returned with the document, but they aren't added to the mapping and aren't indexed, so you can't search or aggregate on them. The document is accepted. The field just isn't queryable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dynamic: strict&lt;/code&gt;: a document containing an unknown field is rejected outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose &lt;code&gt;false&lt;/code&gt;, not &lt;code&gt;strict&lt;/code&gt;, on purpose. &lt;code&gt;strict&lt;/code&gt; would have meant that every time the application started tracking changes on a new field, the audit pipeline would start rejecting events until someone updated the mapping. That couples the audit log's health to every schema change upstream. It's fragile, and it defeats the point of an audit log, which is to never drop a change event. &lt;code&gt;false&lt;/code&gt; keeps the full payload in &lt;code&gt;_source&lt;/code&gt;, so the complete record of what changed is still there and still retrievable. It just isn't turned into a searchable field. I explicitly map the handful of fields I actually query on, the entity identity, the timestamp, a trace id that ties an event back to the request that caused it, and who made the change, and everything else rides along in &lt;code&gt;_source&lt;/code&gt; without bloating the mapping.&lt;/p&gt;

&lt;p&gt;That's the whole trade in one sentence: I keep the data, I drop the ability to query arbitrary fields, and in exchange the mapping stays small and the cluster stays healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade I accepted
&lt;/h2&gt;

&lt;p&gt;I want to be honest about the cost, because &lt;code&gt;dynamic: false&lt;/code&gt; isn't free.&lt;/p&gt;

&lt;p&gt;What I gave up: I can't run an ad-hoc query like "find every audit event where some arbitrary nested field changed to value X," because that field was never indexed. If I genuinely needed that, I'd have to map the field explicitly, ahead of time.&lt;/p&gt;

&lt;p&gt;Why it was the right trade for this data: an audit log is overwhelmingly read by entity. The real query is "show me the change history for this record," and that's served by the fields I did map, the entity type and id and timestamp. The rare case where someone wants the full detail of a specific event is served by &lt;code&gt;_source&lt;/code&gt;, which still has everything. I'm not losing information. I'm losing a query pattern I don't actually use, in exchange for a system that doesn't degrade as the application grows.&lt;/p&gt;

&lt;p&gt;The general principle I took from it: dynamic mapping is a convenience that assumes a bounded set of fields. The moment your data's field set is unbounded by design, and an audit log is the textbook example, that convenience works against you, and you should opt out deliberately rather than discover the field limit in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives: data streams, not a plain index
&lt;/h2&gt;

&lt;p&gt;One more piece, because "set &lt;code&gt;dynamic: false&lt;/code&gt;" is only half the design. An audit log is append-only and time-ordered. You write a continuous stream of events and basically never modify what's already there. That's the case Elasticsearch data streams are built for.&lt;/p&gt;

&lt;p&gt;Instead of writing to a single ever-growing index, a data stream sits in front of a series of backing indices and rolls over to a new one as they grow, while you read and write through one stable name. You configure the whole thing, the &lt;code&gt;dynamic: false&lt;/code&gt; mapping included, once, in an index template, and every backing index the stream creates inherits it. So the mapping discipline isn't something you have to remember to reapply. It's in the template, and every new index the stream rolls into starts out locked down.&lt;/p&gt;

&lt;p&gt;That pairing is the actual design: a data stream for append-only, time-series writes, plus an index template that carries the &lt;code&gt;dynamic: false&lt;/code&gt; mapping so the field set stays controlled no matter how long the log runs or how many indices it rolls through.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway 📝
&lt;/h2&gt;

&lt;p&gt;If you're putting an append-only log into Elasticsearch, audit events, entity-change history, anything where the field set grows with your application, decide your mapping strategy before the field count forces the decision for you. The default will happily index every field your app has ever produced, and the way you find out is the day writes start failing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dynamic: false&lt;/code&gt; plus a small set of explicitly mapped query fields, sitting on a data stream driven by an index template, is the configuration I'd reach for again without thinking twice. It's a small, boring decision, and boring is what you want here. It's the difference between an audit log that quietly runs for years and one that turns into a cluster-state problem you have to firefight later.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>From PHP to Go: what took me longest to rewire</title>
      <dc:creator>Anatolii</dc:creator>
      <pubDate>Mon, 22 Jun 2026 20:16:03 +0000</pubDate>
      <link>https://dev.to/econ__11/from-php-to-go-what-took-me-longest-to-rewire-2nfn</link>
      <guid>https://dev.to/econ__11/from-php-to-go-what-took-me-longest-to-rewire-2nfn</guid>
      <description>&lt;p&gt;I wrote PHP for about seven years before Go became my main language — Laravel for five of them, Yii2 and plain MVC before that. Then I led the rebuild of a Laravel monolith into Go microservices, and later joined a marketplace-product, to work on Go services in production. So I didn't come to Go from a tutorial. I came to it carrying a decade of PHP habits, and I had to ship real systems while unlearning them 🥲&lt;/p&gt;

&lt;p&gt;The syntax was the easy part. You can read Go in an afternoon. What took months to rewire were the &lt;strong&gt;&lt;em&gt;mental models&lt;/em&gt;&lt;/strong&gt; — the default assumptions PHP had built into me about how a program is shaped, how errors move, how a request lives and dies. Some of those assumptions are actively wrong in Go, and they don't announce themselves 😫. They show up as code that compiles, passes review on a tired day, and then behaves in a way you didn't predict 😳&lt;/p&gt;

&lt;p&gt;This is a list of the ones that took me longest. Each is tied to something I actually built, not a textbook example.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. There is no &lt;code&gt;try/catch&lt;/code&gt;, and that is a feature, not a missing one
&lt;/h2&gt;

&lt;p&gt;In PHP I threw exceptions and caught them somewhere up the stack — often far up the stack, in a global handler that turned anything unexpected into a 500. The mental model is: errors travel invisibly until someone decides to look. Most of my code didn't think about failure at all; failure was something that happened to the call stack, above me.&lt;/p&gt;

&lt;p&gt;Go inverts this. A function that can fail returns an &lt;code&gt;error&lt;/code&gt; as its last value, and you, the caller, deal with it right there 🥳:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindUser&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="n"&gt;id&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"find user %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;My first instinct was that this was noise. Coming from &lt;code&gt;try { ... } catch (\Throwable $e) {}&lt;/code&gt;, the &lt;code&gt;if err != nil&lt;/code&gt; after every call felt like ceremony 😅. It took me a while — and a few production incidents — to understand what it buys you: failure becomes part of the visible control flow. You can't not see that a call can fail, because the error is sitting in a variable in front of you. The decision "swallow this, retry this, or pass it up" is made at the exact place that has the most context to make it.&lt;/p&gt;

&lt;p&gt;The habit that took longest to kill was the urge to build a catch-all. In PHP I leaned on the global exception handler. In Go I had to learn to wrap errors with context as they go up (&lt;code&gt;%w&lt;/code&gt; and a short message at each layer) so that by the time an error reaches the top, the message is a breadcrumb trail — "find user 42: query timeout: ..." — instead of a stack trace I have to decode 🥳&lt;/p&gt;

&lt;p&gt;This paid off in a real incident. An order wouldn't create, and the wrapped error that came out the top read essentially 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;create order 8842: charge payment: gateway timeout after 5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line pointed straight at the cause — a downstream service we were calling to charge the payment was misbehaving and timing out. No log spelunking, no guessing which layer failed. The message had assembled itself from each layer adding a little context on the way up, and by the time it reached me it told me exactly where to look. In my PHP days that would have surfaced as a generic 500 and a stack trace I'd have to read backwards 🥲&lt;/p&gt;

&lt;p&gt;What I'd tell a PHP developer: stop looking for &lt;code&gt;try/catch&lt;/code&gt;. The &lt;code&gt;if err != nil&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; your error handling, and writing it everywhere forces you to actually think about each failure instead of deferring all of them to one handler that prints "Something went wrong." 👍&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The request is not the unit of life anymore
&lt;/h2&gt;

&lt;p&gt;This was the deepest shift, and the one I underestimated most.&lt;/p&gt;

&lt;p&gt;In PHP, the model is shared-nothing per request. A request comes in, the framework boots, you handle it, the process tears everything down, and the next request starts from a clean slate. Memory leaks barely matter — the worst case is one request. Global state is reset for you. You almost never think about two requests touching the same variable, because in the classic PHP model they physically can't; they're separate processes 🙂&lt;/p&gt;

&lt;p&gt;Go is the opposite. The process is long-lived. One Go binary stays up and handles thousands of requests concurrently, in the same memory, often literally at the same time across goroutines. The moment I internalized that, a whole category of bugs I'd never had to think about became something I had to actively watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A package-level variable is now shared across every concurrent request. In PHP that was a per-request convenience. In Go it's a data race waiting to happen.&lt;/li&gt;
&lt;li&gt;A map written to by two requests at once will crash the whole process — not the one request, the &lt;strong&gt;whole binary&lt;/strong&gt;. The PHP blast radius of "one bad request" doesn't exist; a panic from a concurrent map write can take down everything in flight 😢&lt;/li&gt;
&lt;li&gt;Resources you open have to be closed deliberately, because nothing is tearing the world down after each request to clean up after you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are exotic. They're the baseline things you now have to keep in your head on every change, because the language and the runtime won't reset the world for you between requests the way PHP did. The point isn't a specific disaster — it's that a whole class of failure that was simply impossible in the per-request PHP model is now possible by default, and avoiding it is on you 🧐&lt;/p&gt;

&lt;p&gt;Rewiring this meant changing my default question. In PHP I asked "what does this request need?" In Go I had to ask "what happens when a thousand of these run at once, in the same memory?" That question is now automatic, but it took real production exposure to make it automatic.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Concurrency is in the language, so you own correctness
&lt;/h2&gt;

&lt;p&gt;PHP's concurrency story, for most of my career, was "use a queue and more workers." Parallelism lived outside the language — in the infrastructure, in separate processes, in something like a job queue. I rarely reasoned about two things touching the same data in the same memory, for the same reason as above: they usually couldn't.&lt;/p&gt;

&lt;p&gt;Go puts concurrency in my hands directly. &lt;code&gt;go someFunc()&lt;/code&gt; starts a goroutine 👍. Channels pass data between them 👍. It's genuinely powerful, and it's the reason Go fit the kind of services we were building 🥳. But the power comes with ownership: the language hands you concurrency and then holds you responsible for correctness. A goroutine that writes to a shared structure without coordination is a bug that may pass every test on your machine and only surface under real load 🥲.&lt;/p&gt;

&lt;p&gt;Two specific habits I had to build that PHP never required:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reach for the race detector early.&lt;/strong&gt; In my own projects I run tests with &lt;code&gt;-race&lt;/code&gt;, and it's caught real races a few times now — races I'd never have spotted by reading the code, because they only show up when goroutines happen to interleave the wrong way at run time 🙏. In PHP I never had a tool like that, because I never had the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide deliberately how goroutines share data&lt;/strong&gt; — pass copies, use a channel, or protect shared state with a mutex — instead of just sharing a variable because it's in scope. "It's in scope so I'll use it" is a perfectly safe PHP habit and a dangerous Go one ☝️&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental shift: in PHP, concurrency was an infrastructure concern I delegated. In Go, it's a &lt;em&gt;code&lt;/em&gt; concern I own line by line.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. &lt;code&gt;context.Context&lt;/code&gt; is the spine, not a parameter you tolerate
&lt;/h2&gt;

&lt;p&gt;When I first saw &lt;code&gt;ctx context.Context&lt;/code&gt; as the first argument of seemingly every function, I treated it the way I'd treated similar things in PHP frameworks — boilerplate to thread through and otherwise ignore. That was wrong, and it cost me before it clicked 😅.&lt;/p&gt;

&lt;p&gt;In PHP, the request was bounded for me. When the client disconnected or the request finished, the process ended; I never had to manually propagate "this work should stop now." In a long-lived Go service, nothing stops your work automatically. If a client gives up, or a request times out, the goroutines doing the work for that request will happily keep running — querying the database, calling other services — for no one. &lt;code&gt;context.Context&lt;/code&gt; is how cancellation and deadlines travel down through every call so that work can actually be stopped and resources released.&lt;/p&gt;

&lt;p&gt;Once I understood it as &lt;strong&gt;&lt;em&gt;the cancellation and deadline spine of the request&lt;/em&gt;&lt;/strong&gt;, passing &lt;code&gt;ctx&lt;/code&gt; everywhere stopped feeling like boilerplate and started feeling like the thing that keeps a long-lived service from leaking work 🙌. In a marketplace with a lot of concurrent traffic, "stop doing work nobody is waiting for" is not a nicety; it's how the service stays healthy under load.&lt;/p&gt;

&lt;p&gt;A concrete way I use it: when I call another service, I put a deadline on the context — lets say around 5 seconds. If that downstream call runs past the deadline, the context fires, I stop waiting, and I return a clean degraded response to the client — something like "we can't process this right now, please try again in a few minutes" 🤔 — instead of leaving the request hanging forever and tying up resources behind it. That's the whole point of the spine: the deadline travels down with the call, and when it expires everyone downstream can give up together. Under load, failing fast and politely is far better than hanging, and &lt;code&gt;context.Context&lt;/code&gt; is what makes that possible without threading a timeout flag through every function by hand 👌&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Composition over inheritance — and Laravel had hidden how much I leaned on inheritance
&lt;/h2&gt;

&lt;p&gt;This one was subtle because I didn't realize how much of my PHP design instinct was inheritance-shaped until Go took the option away 😎&lt;/p&gt;

&lt;p&gt;In Laravel, so much is built on extending base classes — your controllers, your models, your form requests all inherit a large amount of behavior from the framework. The "right" way to add capability was often "extend the base class." That instinct is invisible while you have it; it just feels like how code is organized.&lt;/p&gt;

&lt;p&gt;Go has no class inheritance (in usual meaning). It has struct embedding and, more importantly, interfaces that are satisfied &lt;em&gt;implicitly&lt;/em&gt; — a type implements an interface just by having the right methods, with no &lt;code&gt;implements&lt;/code&gt; keyword and no declared relationship. Coming from PHP's explicit &lt;code&gt;class Foo extends Bar implements Baz&lt;/code&gt;, implicit interfaces felt almost too loose at first. Where's the contract? Who guarantees it? 🤯&lt;/p&gt;

&lt;p&gt;What rewired it for me was using this pattern consistently at the marketplace-project. There, I define interfaces at the consumer for repositories, for internal services, and for external services — a small interface declared &lt;em&gt;where it's used&lt;/em&gt;, and any type with the right methods satisfies it. The consumer declares only the handful of methods it actually needs; the provider doesn't have to know the interface exists. That's a very different shape from "everyone extends the framework's base class," and it produces code where dependencies are small and swapping an implementation is trivial — a real database behind a repository in production, a fake satisfying the same interface in a test. Writing tests stopped requiring a whole framework's worth of scaffolding and started being a matter of passing in a small fake that fits the interface 🥳&lt;/p&gt;

&lt;p&gt;Coming to that from years of Laravel's &lt;code&gt;extends&lt;/code&gt;-everything instinct is exactly why I can feel how different it is. A developer who only ever wrote Go might take implicit interfaces for granted; I had to consciously give up the inheritance reflex to get there.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Explicit beats clever, and the language enforces it
&lt;/h2&gt;

&lt;p&gt;PHP let me be clever. Dynamic typing, magic methods, arrays that were lists and maps and objects depending on my mood — a lot of expressiveness, and a lot of rope. I wrote some clever PHP I was proud of and later could not fully reconstruct why it worked 😅&lt;/p&gt;

&lt;p&gt;Go is deliberately boring in a way that annoyed me at first and that I now value. No magic methods. No implicit type juggling. The compiler refuses unused variables and unused imports. The formatting is not up for debate — &lt;code&gt;gofmt&lt;/code&gt; decides, and the entire community's code looks the same. Coming from PHP's freedom, this felt like the language not trusting me 🤔&lt;/p&gt;

&lt;p&gt;The reframe: Go optimizes for the code being &lt;em&gt;read&lt;/em&gt;, not the code being &lt;em&gt;written&lt;/em&gt;. On a team — and I was leading one through the rebuild, plus interviewing developers now — that tradeoff is obviously correct. Clever PHP is a liability the moment someone other than its author has to maintain it. Boring, explicit, uniformly-formatted Go is something a teammate can read at 2am during an incident and actually understand. The language took away cleverness, and what I got back - a code that my team could understand about the same way I do 😎&lt;/p&gt;

&lt;p&gt;That, more than any single feature, is the mental model I'd most want a PHP developer to adopt before they write a line of Go: you are not writing for the interpreter's flexibility anymore. You are writing for the next person who reads it, and the language is going to hold you to that whether you like it or not 🧐&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd actually tell someone making this move
&lt;/h2&gt;

&lt;p&gt;The syntax will take you a week. The mental models took me months, because the hard part isn't learning Go — it's &lt;em&gt;unlearning&lt;/em&gt; the PHP assumptions that are so deep you don't know they're assumptions.&lt;/p&gt;

&lt;p&gt;None of this is a complaint about PHP. Seven years of PHP is exactly what made the Laravel-to-Go migration something I could lead rather than just attend — I understood the system we were leaving as deeply as the one we were building. But the move only worked once I stopped writing Go in PHP's syntax and started actually thinking in Go.&lt;/p&gt;

</description>
      <category>go</category>
      <category>learning</category>
      <category>microservices</category>
      <category>php</category>
    </item>
  </channel>
</rss>
