[{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/categories/ai--llm/","section":"Categories","summary":"","title":"AI \u0026 LLM","type":"categories"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/ai-commit-messages/","section":"Tags","summary":"","title":"Ai-Commit-Messages","type":"tags"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/categories/developer-productivity/","section":"Categories","summary":"","title":"Developer Productivity","type":"categories"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/developer-productivity/","section":"Tags","summary":"","title":"Developer-Productivity","type":"tags"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/authors/eugene/","section":"Authors","summary":"","title":"Eugene","type":"authors"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/llm-agents/","section":"Tags","summary":"","title":"Llm-Agents","type":"tags"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/pull-requests/","section":"Tags","summary":"","title":"Pull-Requests","type":"tags"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/categories/software-development/","section":"Categories","summary":"","title":"Software Development","type":"categories"},{"content":"","date":"1 June 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" TL;DR # I used to have pull requests with dozens (and sometimes hundreds) of commits, and most commit messages were complete garbage. Cleanup., More tests., fix., some initial notes \u0026ndash; you got the idea.\nThe fix was not discipline. Discipline does not scale very well when you commit often, work with AI agents, constantly jump between projects and branches, and still want to keep flow. The fix was two tiny prompt files:\ncommit.md reads the staged diff and creates a short descriptive commit message. create-pull-request.md reads the commit log, generates a PR title and description, pushes the branch, and runs gh pr create. Good commit messages become good PR descriptions. Good PR descriptions give your teammates better review context. And now your AI agent also has better history to inspect later. This is not a complex system. That\u0026rsquo;s exactly why I like it.\nVideo Walkthrough # If you prefer a visual walkthrough, I made a video for this post where I show how these two prompts fit into my AI-assisted Git workflow.\nThe Problem Is Not That You Cannot Write Good Commits # You can write good commit messages. I can too.\nThe problem is that we usually do not. Not every time. Not when the change is small. Not when it is late. Not when you\u0026rsquo;re in the middle of agentic engineering flow and you just want to checkpoint before the next step. And especially if you\u0026rsquo;re manually coding some complex edge case (yes I still do it sometimes), context switch is not fun.\nI had a real pull request with 209 commits. Mine. A lot of the messages were things like:\nCleanup. More tests. Some initial notes. More readme. fix. more changes. Now sure, you will squash before merging. But what does the squashed commit say? Very often it says some slightly better version of nothing. And if GitHub is using default merge commit behavior, you may not even get the PR title and description in the final commit unless you configure it, or manually write it again!\nAnd you may say, well \u0026ldquo;Who reads commit messages?\u0026rdquo; and well, yeah, usually nobody. Until you and your AI buddy are hunting a regression across 200 commits for the past three months, with production being on fire. Then everyone suddenly cares very much about what changed and why.\nCommit messages are not ceremony. They are your future search index and journal of your work.\nSo let\u0026rsquo;s jump right in and show you how to fix this, just two prompts and that\u0026rsquo;s it!\nPrompt One: commit.md # The first file is the commit prompt. It can live anywhere your agent can reference it. I usually keep this kind of stuff under .agents/commands/ or similar and then symlink or reference it as harness specific command. I personally prefer to design my prompts/skills/commands in a more or less harness agnostic way, but if you use just a single harness/vendor, it maybe best to just stick to your harness way of defining commands.\nAnd the file is just those few lines:\n# Follow this instruction to commit changes. * All commands should be run from a repo root. * Commit all updated files if not otherwise specified. Use `git add .`. * Use `git diff --staged | head -n 1000` to understand the change. * Create a short descriptive commit message based on the diff. * Use chat history only as extra context, not as the source of truth. Do **NOT** do any other verification or actions unrelated to this instruction. If you don\u0026rsquo;t force the model to read the diff, it will happily use chat history or just list changed files and come-up with slightly longer but equally useless message. The staged diff is the thing you are actually committing.\nAnd it doesn\u0026rsquo;t matter if you run this command or you agent does it while running some big orchestration loop, the result is the same: you get a commit message that describes the change.\nThe Guardrail Line # This line matters more than it looks:\nDo **NOT** do any other verification or actions unrelated to this instruction. Without it, helpful agents may sometimes try to be too helpful. They may run lint and tests. They may format files. Maybe they start fixing something if they spot a \u0026ldquo;bug\u0026rdquo; in that diff\u0026hellip; Suddenly your five-second checkpoint becomes five-minute battle field of your agent with the codebase and you\u0026rsquo;re lucky if you notice and stop this madness.\nThe prompt has one job: commit current changes with a useful message and this \u0026ldquo;magic\u0026rdquo; line is a sort of a guardrail to make sure it doesn\u0026rsquo;t do anything else.\nPrompt Two: create-pull-request.md # The second prompt is the companion file. It creates the PR from the commit history. It can work on its own, but they work best together. As mentioned - good commits make good PR descriptions.\nThe prompt is a bit longer, but conceptually it is doing the following:\ngit fetch origin - well you know what that is git log origin/main...HEAD --oneline | cat - list of nicely formatted commit messages (produced by commit.md ideally) Synthesize title and description based on the commit log git push and gh pr create Nothing fancy, just a few commands and a bit of text processing. So here is the full prompt:\n# Instruction to create pull request This is an instruction to follow when user is referencing it. Only use this instruction when explicitly requested by the user. 1. Look on a commit history between a base (if not mentioned otherwise, use **main**). You will need to run command like below ```bash # Make sure remote is up to date git fetch origin # to get current branch, you will use it in step 5 git branch # git log git log origin/\u0026lt;base branch\u0026gt;...HEAD --oneline | cat ``` 2. Review commit history (git log output above) and come up with a sensible PR title and description. 3. The description should summarize meaningful changes and follow format similar to below: ```md # Focus of the changes \u0026lt;Short summary of the changes (1-2 sentences), what they are about and why they are needed.\u0026gt; # High level summary of the changes * Short change description 1 * Short change description 2 * ... ``` Note: Use your best judgement and feel free to adjust the format if changes are more complex and require more detailed description. The main point is to make it easy for reviewers (usually humans, and humans do not like to read poems) to understand what changes are about and why they are needed. 4. Prepare PR title and description in the following format: ```md **PR title**: \u0026lt;PR title\u0026gt; --- **PR description**: \u0026lt;PR description\u0026gt; ``` 5. Push pending changes and create a PR with a command below: ```bash git push origin \u0026lt;current branch\u0026gt; --set-upstream gh pr create --title \u0026#34;\u0026lt;PR title\u0026gt;\u0026#34; --base \u0026lt;base branch\u0026gt; --head \u0026lt;current branch\u0026gt; --body \u0026#34;$(cat \u0026lt;\u0026lt;EOF # Focus of the changes \u0026lt;Short summary of the changes (1-2 sentences), what they are about and why they are needed.\u0026gt; # High level summary of the changes * Short change description 1 * Short change description 2 * ... EOF )\u0026#34; ``` Note: Using cat and EOF allows you to create a multi-line PR description without worrying about escaping special characters. Make sure to use actual PR title and description and other parameters as needed. 6. Show the PR to the user as a URL so user can click it, as well as full URL for copying. And you can easily tune what you want to see in the PR title and description, it\u0026rsquo;s just a text prompt, so adjust it to your needs.\nHow do I run this? # For the commit.md one, I usually do it like this:\nRunning it manually when I want to check-point my work (e.g /commit or just follow @commit.md). And this mode is probably not as frequent these days. Have my agent run it while in progress of some long running orchestration loop. In this case it\u0026rsquo;s usually part of a verification step that is executed in a dedicated sub-agent. The create-pull-request.md is basically same story, running it either myself or my agent runs it when it\u0026rsquo;s time to submit the PR.\nBefore And After # Typical commit messages produced the \u0026ldquo;old way\u0026rdquo;:\n- cleanup - fix - more changes - update tests - final tweaks And this is what you can expect from the \u0026ldquo;new way\u0026rdquo;:\n- Add AccountsStore load/validate/query and tests - Add cmd/bbmd Cobra scaffold and DI smoke tests - Implement bbmd auth sub-commands - Implement bbmd pr sub-commands - bbmd: unify auth/PR execution via exec helpers and tests And of course PR title and description are way better. Here is one of the PRs I created with this workflow: https://github.com/gemyago/atlacp/pull/38\nIf something goes wrong # If you notice some misbehavior with those prompts, I have a very handy technique that helps to diagnose and fix such misbehavior - Agent Diagnostics Mode. You can use this technique to fix issues with your prompts, not just those commit/pull request ones, but in general - any model misbehavior. This was a handy tool for me and still is.\nModel Choice: Please Do Not Use Your Most Expensive Model # Using a premium reasoning model for commit messages is probably too much. Sure if it\u0026rsquo;s one-off and you just don\u0026rsquo;t want to leave the session - fine. But doing it all the time will burn your budget quicker, especially if your agent is orchestrating some big work and doing checkpoints often.\nUse whatever fast and cheap model you have access to, experiment with it to make sure it is handling the task well, maybe even tune the prompt if it\u0026rsquo;s confusing to the model. Local models may also be interesting here, especially for housekeeping prompts like this one.\nSave your budget on complex stuff, commit messages do not need that.\nIs this lazy? # Maybe. But it is the good kind of lazy.\nThe output is better, the friction is lower, and the history is more useful. I will take that trade-off.\n","date":"1 June 2026","externalUrl":null,"permalink":"/posts/ai-commits-and-prs/","section":"Posts","summary":"Commit messages usually become write-only noise exactly when you need them most. Here are two small AI prompt files I use to turn diffs into useful commits, and useful commits into better PR descriptions.","title":"Two AI Prompts That Fixed My Git History","type":"posts"},{"content":"This is the homepage of the Crafted Bytes blog. Here you can find articles on various topics related to software development, programming, and technology. Stay tuned for regular updates and insights from our team of experts.\n","date":"1 June 2026","externalUrl":null,"permalink":"/","section":"Welcome to Crafted Bytes","summary":"","title":"Welcome to Crafted Bytes","type":"page"},{"content":" The Problem with Prompt Tuning Today # Prompts are not static configuration. If you have been running LLM-powered agents on real projects for more than a few months, you already know this. A prompt that worked perfectly last quarter drifts after a model update. A system instruction that produced reliable behavior on one agent — say, Cursor — behaves differently when you port it to Gemini or Claude. And the same prompt file can produce subtly inconsistent results across projects as the surrounding context changes.\nThe usual response to this is ad-hoc: you notice something is off, exit the working conversation, edit the prompt file, re-run the agent, and try to reconstruct the context you had before. That friction compounds. You lose the conversational thread. You lose the intermediate reasoning the model had built up. And you are basically doing print-statement debugging on a system that has no stack trace.\nThe problem is not that prompt tuning is hard. The problem is that there is no structured diagnostic loop for doing it inside the working context. As for me, this lack of structure has been a recurring pain point.\nVideo Walkthrough: The Diagnostics In Action # If you prefer a visual deep dive, I have a video for this post. I walk through the \u0026ldquo;Westworld\u0026rdquo; analysis vibe in real-time, showing exactly how the model pivots from \u0026ldquo;doing\u0026rdquo; to \u0026ldquo;reporting\u0026rdquo; in a live project.\nQuick Start: Entering and Exiting Diagnostics Mode # The mechanic is simple. I keep a small file at .context/agent-diagnostics-mode.md:\n# Agent Diagnostics Mode If user is referencing this prompt, you are in self-diagnostics mode. The goal of this mode is typically to understand your reasoning process, decision making, actions or results. Typically the user is trying to optimise your performance or behavior and tune their instructions to you. The user may typically ask you something along with this prompt. You should analyse the user enquiry and report your findings based on the user enquiry. **IMPORTANT:** You MUST NOT do any actions or modifications - just analyse and report your findings based on the user enquiry. To enter diagnostics mode on any agent that supports referenced prompt files (are there any that do not?), I include this file in my message. The effect is immediate and significant: the model pivots from doing to reporting. It stops planning tasks and starts articulating its reasoning.\nHere is a real example from one of my Go projects. My AGENTS.md defines two distinct task completion protocols for example: a Coding Task Completion Protocol (run make lint, run make test, confirm coverage, report status) and a Non-Coding Task Completion Protocol (summarize findings, confirm deliverables, no lint/test required). The intent is obvious: code changes require verification, everything else does not.\nThe model started skipping lint and tests after refactoring a package. It classified the task as non-coding — I suspect because the instruction I gave was phrased around reorganizing files, which pattern-matched to \u0026ldquo;documentation/investigation\u0026rdquo; rather than \u0026ldquo;code change.\u0026rdquo; The refactor changed Go files. Lint and tests were mandatory. But the agent declared completion with just a summary.\nEnter diagnostics:\n@agent-diagnostics-mode.md\nI just asked you to move a package and update its imports across the codebase. You reported completion without running make lint or make test. Which task completion protocol did you apply, and what in my instructions led you to that classification?\nThe agent responds with a behavioral report — not an action. It identifies that it applied the Non-Coding protocol, cites the exact section it matched against (\u0026ldquo;investigation, documentation review, committing\u0026rdquo;), and notes that the ambiguity came from my use of the word \u0026ldquo;reorganize\u0026rdquo; — a word it associated with structural/documentation work rather than code modification. No files are changed. No commands are run.\nExit diagnostics:\nExit diagnostics mode. Now update AGENTS.md so that the Coding Task Completion Protocol explicitly lists package moves and import updates as coding tasks, regardless of how the instruction is phrased.\nThe model carries the diagnostic conclusion forward and applies the fix in one step. The conversation context is the working memory — no reconstruction needed.\nNo special tooling is required here. This is a pure prompt technique.\nSide note: every time I type @agent-diagnostics-mode.md I think of Westworld — where the hosts switched to \u0026ldquo;Analysis\u0026rdquo; mode on a simple voice command. Same vibe, but instead of uncovering suppressed memories, you are uncovering why your agent skipped make test. Feels like the future we\u0026rsquo;re living in — there were no flying cars in that movie, so I guess we\u0026rsquo;re almost there. :)\nWhy This Works: The Architecture Behind the Mode # Why LLMs Respond to Mode Declarations # LLMs are sensitive to framing. This is not a bug — it is how the attention mechanism distributes probability mass across the context window. When you explicitly declare a behavioral mode by referencing a named instruction file, you are shifting the model\u0026rsquo;s prior distribution over likely next tokens. The behavioral envelope changes without any retraining.\nThe critical constraint in the diagnostics prompt is MUST NOT do any actions or modifications. This is not politeness. It is load-bearing. Without it, many models — especially those optimized heavily for task completion — will collapse the investigation phase into an implementation. They will diagnose the issue and then helpfully fix it before you have had a chance to evaluate whether the diagnosis was correct. That is exactly the failure mode you are trying to avoid.\nBy separating the diagnostic phase from the implementation phase, you get something closer to a red team exercise than a development sprint. The model is not trying to be useful in the conventional sense. It is trying to be accurate.\nProbing for Behavior vs. Triggering It # There is a meaningful distinction between asking an LLM to do something and asking it to explain what it would do and why. Diagnostics mode exploits that gap. You can surface hidden assumptions, expose conflicting instructions, and simulate edge cases — all without mutating any project state.\nAs for me, the most valuable use has been catching prompt assumptions I wrote months ago and forgot about. The model cites them back and precisely explains why it chose them.\nPrompt Tuning as a Feedback Loop # The full workflow is a tight loop: enter diagnostics → interrogate → identify the misalignment → exit → apply change. Engineers will recognize the shape — it is the REPL, or unit test isolation. The difference is that the conversation context itself is the working memory, your prompts are like code files, and you\u0026rsquo;re using the model as a debugger of its own behavior.\nEdge Cases and Failure Modes # Mode bleed. Some models ignore the declaration and keep taking actions. Add an explicit confirmation step — \u0026ldquo;Confirm you are in diagnostics mode before proceeding\u0026rdquo; — which forces the model to process the constraint before it does anything else.\nContext window exhaustion. Long diagnostic threads are expensive. If responses start sounding vague or recycled, the effective context is likely saturated. Keep sessions focused on one prompt file at a time; open a fresh context for distinct concerns.\nModel-specific variance. Instruction-following models — Claude Sonnet, GPTs — respect the MUST NOT constraint more or less reliably. Some other models I\u0026rsquo;ve used may not. Also, some models tend to produce quite verbose diagnostics output which may be hard to analyze. I\u0026rsquo;ve seen this behavior with grok-code-fast.\nFurther Reading # Prompt Engineering and Instruction Design # Anthropic: Prompt Engineering Overview — A practical reference for constructing reliable system prompts; directly relevant to writing the kind of mode-declaration instructions used in this technique. OpenAI: Prompt Engineering Guide — Covers the mechanics of how instruction phrasing affects model behavior, which explains why word choice like \u0026ldquo;reorganize\u0026rdquo; causes protocol misclassification. LLM Attention and Context Behavior # Attention Is All You Need (Vaswani et al., 2017) — The original transformer paper; reading this gives you precise intuition for why framing and token ordering influence how models weight instructions. Agent Instruction Patterns # golang-backend-boilerplate AGENTS.md — A real-world example of the task completion instruction file referenced in this post; useful as a template for structuring your own agent rules. ","date":"21 February 2026","externalUrl":null,"permalink":"/posts/agent-diagnostics-mode/","section":"Posts","summary":"A structured diagnostic loop for prompt tuning inside the working context. Learn how to pivot AI agents from doing to reporting for better reasoning analysis.","title":"Agent Diagnostics Mode — A Structured Technique for Iterative Prompt Tuning","type":"posts"},{"content":"","date":"21 February 2026","externalUrl":null,"permalink":"/tags/ai-tools/","section":"Tags","summary":"","title":"Ai-Tools","type":"tags"},{"content":"","date":"21 February 2026","externalUrl":null,"permalink":"/tags/debugging/","section":"Tags","summary":"","title":"Debugging","type":"tags"},{"content":"","date":"21 February 2026","externalUrl":null,"permalink":"/tags/productivity/","section":"Tags","summary":"","title":"Productivity","type":"tags"},{"content":"","date":"21 February 2026","externalUrl":null,"permalink":"/tags/prompt-engineering/","section":"Tags","summary":"","title":"Prompt-Engineering","type":"tags"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/architecture-patterns/","section":"Tags","summary":"","title":"Architecture-Patterns","type":"tags"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/backend/","section":"Tags","summary":"","title":"Backend","type":"tags"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/categories/backend-development/","section":"Categories","summary":"","title":"Backend Development","type":"categories"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/clean-architecture/","section":"Tags","summary":"","title":"Clean-Architecture","type":"tags"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/golang/","section":"Tags","summary":"","title":"Golang","type":"tags"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/microservices/","section":"Tags","summary":"","title":"Microservices","type":"tags"},{"content":" TL;DR # In this post I\u0026rsquo;m going to share a Pragmatic Layered Architecture that I\u0026rsquo;m following on most of my projects. This is a battle-tested approach that I\u0026rsquo;ve been practicing and polishing for years now (and still learning). It embodies some principles from Clean or Hexagonal Architecture with strong focus on practical aspects but not purity or dogma.\nThe concept in a nutshell:\nThree-layer structure: API → Application → Infrastructure. Dependencies (imports) flow inward towards the app (reminds hexagonal). Will explain later why this I follow this. Application layer - application logic lives here. Defines types and interfaces for infrastructure. Minimal mapper tax: Minimal mapping/enforcement. Will explain later where and why. Infrastructure is a tiny Anti Corruption Layer with little to no logic, just database calls or external interactions. These principles work in any language—whether you\u0026rsquo;re building. I will later share an example boilerplate repo written in Go with all the concepts demonstrated, however I\u0026rsquo;ve applied same principles in TypeScript and Java, of course keeping in mind language idioms, but the core ideas remain the same.\nDogmatic Architecture Problem # Most architecture types such as hexagonal, onion or clean architecture tell you to build abstractions, keep strict boundaries and isolate the domain. The domain must be pure, independent from frameworks, databases, and external systems.\nHowever the reality is more nuanced. It is very uncommon to switch databases, cloud providers or other third party systems on a real world projects. Sure, sometimes your project must support polymorphic environments, but most of the time you pick a database and stick with it for years. And if you have to migrate, usually the coding part is the smallest chunk of the work. Data migration, testing, deployment, cutover planning are way more complex and time consuming.\nI\u0026rsquo;m not saying that abstractions are completely useless, the question is how much abstraction is enough? Where to draw the line between purity and pragmatism?\nLet\u0026rsquo;s consider two extremes:\nFollow architecture diagrams religiously → 12 files to save a user. UserDTO, UserDomain, UserEntity, UserDAO\u0026hellip;. (you got the idea). Mappers everywhere. Pass-through code is common. Understanding the flow requires keeping this all in mind. Onboarding is slow, cognitive load is high. Well AI will probably be happy with this (and AI vendors as well while you\u0026rsquo;re burning tokens), but humans struggle.\nStart too simple → No layers, flat structure, three months in, and testing becomes a nightmare. All tests are integration. Covering simple edge case requires spinning up entire stack. Flakiness is a norm, you are retrying tests constantly. Making any change is risky because you can\u0026rsquo;t be sure what will break. AI also struggles because there is no clear structure.\nIn this article I will show you how I balance these two extremes, keeping testability from day one, enforcing boundaries where it\u0026rsquo;s most valuable, softening rules where it makes sense. Generally keeping the code comprehensible for both humans and AI.\nThe Three Layers # Let\u0026rsquo;s start from the layers. The three layers are: API Layer, Application Layer, Infrastructure Layer. I\u0026rsquo;ll talk about each in detail later, but here\u0026rsquo;s the high level overview:\nImportant thing here is the \u0026ldquo;call flow\u0026rdquo; versus the dependencies flow. The call flow (uses arrows) is obviously from top (API) to bottom (Infra)—nothing special there. But the dependencies flow (the imports, the \u0026ldquo;depends on\u0026rdquo; arrows) goes inward toward the application layer.\nWhy dependencies flow inward:\nClear ownership of requirements: When the application layer defines UserRepository interface, you see exactly what the business logic needs from infrastructure. The application is in control. I do not attempt to abstract away the infrastructure layer too much (at least not by default). Just define the interface with methods you need, keeping your infrastructure capabilities in mind.\nPrevents circular dependencies: Inward flow naturally eliminates import cycles. API and Infrastructure both depend on Application, but never on each other. Depending on your language, this issue may be less of a pain, but it works very well in Go. TypeScript strictly speaking allows circular dependencies, but as for me this is a code smell and I would rather avoid it.\nIncremental refactoring option: If you later need stricter boundaries or full hexagonal architecture, the foundation exists. But unlike dogmatic approaches, you\u0026rsquo;re not paying the abstraction tax upfront—you\u0026rsquo;re just following a dependency direction that makes testing easier today.\nThe Application Layer # The application layer contains all business logic and defines the data structures and all the infrastructure that it needs. I prefer to split commands and queries into separate components, which is CQRS pattern. But don\u0026rsquo;t picture full blown CQRS with event sourcing and other stuff here, it\u0026rsquo;s just a separation of reading and mutating parts of your application logic. Let\u0026rsquo;s discuss why.\nCommands: Where Business Logic Lives # Commands handle state mutations. Usually things like validation, business rules, transactions and similar kind of stuff is happening here. This is usually the most logic heavy part of your system.\nBecause of this complexity, this part is way more practical to be unit tested with mocked out dependencies.\nQueries: Reading Layer # Queries handle data retrieval. In many applications, reads are way more common than writes (think 90% reads, 10% writes or even less). In most typical cases queries are translating to database SELECTs (or any other query language your DB has) and then fetching the results. Because of this, I prefer to integration test queries directly against the real database.\nDirect SQL on Application Layer for Queries # You may define all your queries on infrastructure layer as well, but this very often leads to a \u0026ldquo;pass-through\u0026rdquo; code like this:\ntype UserQueries struct { repo UserRepository // Infrastructure layer interface } func (q *UserQueries) GetUserByEmail(ctx context.Context, email string) (*User, error) { return q.repo.GetUserByEmail(ctx, email) // Just passes through } This code adds no value, just boilerplate. Instead, I prefer to allow SQL queries on application layer. I\u0026rsquo;m not pretending that I might change the database later. And even if it happens, refactoring and moving this code on infrastructure layer is a mechanical task that AI can handle easily.\nSo here is how I do it:\ntype UserQueries struct { // Tiny interface that allows running SQL databaseQuerier Queryer } func (q *UserQueries) GetUserByEmail(ctx context.Context, email string) (*User, error) { var user User query := \u0026#34;SELECT id, name, email FROM users WHERE email = ?\u0026#34; err := q.databaseQuerier.QueryRowContext(ctx, query, email).Scan(\u0026amp;user.ID, \u0026amp;user.Name, \u0026amp;user.Email) return \u0026amp;user, err } Then you spin up a real database connection in tests, write some test data to the DB, and verify that your queries work as expected. Just make sure to use a separate database for tests—this will make your life way easier :).\nReusable queries and pass-through # One challenge with this approach is query reuse. This is the case where I move such queries to infrastructure layer and then use everywhere it needs to be used.\nThis, however, leads to potential pass-through code in case you also need to expose this query to the outside. So you may still end up with pass-through code like this:\ntype UserQueries struct { repo UserRepository // Infrastructure layer interface } // GetUserByID is reused and exposed outside func (q *UserQueries) GetUserByID(ctx context.Context, userID string) (*User, error) { return q.repo.GetUserByID(ctx, userID) // Just passes through } Some languages may allow you to slightly simplify this, for example in TypeScript you can just \u0026ldquo;re-expose\u0026rdquo; the method without defining a new one, something like this:\n// Your repository type UserRepository = { getUserByID(userID: string): Promise\u0026lt;User | null\u0026gt;; // other methods... } // Your queries exposing the same method type UserQueries = { getUserByID: UserRepository[\u0026#39;getUserByID\u0026#39;]; }; function createUserQueries(repo: UserRepository): UserQueries { return { // just re-exposing the method getUserByID: repo.getUserByID, // other methods... }; } Note: In TypeScript I prefer functional approach with composition. If you do classes, you may still be able to do it, but slightly differently (and maybe not as elegant). Strictly speaking in Go you can also do something similar with embedding, but it looks ugly and less readable as for me.\nPass-through alternative # There is an alternative to this - allow repositories to be used from outside (e.g on API layer). Maybe it\u0026rsquo;s ok to do that, but I prefer not to, I prefer consistency in this regard - outside is only interacting with application via commands or queries. Though this thing stays open in my mind, we\u0026rsquo;re defining infrastructure on application layer so repositories are strictly speaking an application concept\u0026hellip; Anyway not sure what\u0026rsquo;s the best here, so sticking to the original approach for now.\nSQL Spread Across Layers # One common question is \u0026ldquo;where should SQL live\u0026rdquo;? In this approach, SQL can live on both application and infrastructure layers. I\u0026rsquo;m ok with that. I\u0026rsquo;ll probably repeat myself, but database swap is a rare scenario, and pass-through is more annoying than having SQL in two layers. I think it\u0026rsquo;s important to agree on this with your team and be consistent. And this, BTW, is a good rule to add to an AGENTS.md file so AI also knows about it.\nInfrastructure Layer # The infrastructure layer holds implementations for all the application defined infrastructure interfaces. It\u0026rsquo;s the only layer that deals directly with databases, SQL, Pub/Sub, external APIs or anything else of this kind.\nIn Clean or Hexagonal architecture you may hear that application should be infrastructure agnostic, however I prefer to be pragmatic here. You\u0026rsquo;re spending hours picking particular database (usually Postgres :)), or researching if given Pub/Sub system fits your needs, and then what? abstract it away\u0026hellip;? this sounds silly to me.\nI prefer to think of this layer as a tiny ACL (Anti Corruption Layer) that allows you to keep your naming conventions, use stricter data types and things like that (e.g UUID vs string for IDs or Date in TypeScript). You may also want to move some serialization here as well. But other than that, this layer should be as thin as possible.\nIf you want ACID transactions on the application layer, just use them and there are number of techniques that allow you to do it in a more or less \u0026ldquo;clean\u0026rdquo; way. For example in Go I might define a Transactor (similar to Querier I mentioned before) and then have your repository methods require transaction context, for example:\ntype Tx // Transactor allows DI and unit tests with mocks. // It conforms to sql.DB type Transactor interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) } type UserRepository interface { // CreateUser supports transactional context CreateUser(ctx context.Context, tx *sql.Tx, user User) error // other methods... } // Then your service or command handler would look like this: func (s *UserService) RegisterUser(ctx context.Context, req RegisterUserRequest) error { tx, err := s.Transactor.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() err = s.userRepo.CreateUser(ctx, tx, User{...}) if err != nil { return fmt.Errorf(\u0026#34;failed to create user: %w\u0026#34;, err } // do other transactional work... return tx.Commit() } In other languages you might follow similar patterns, however the concept stays the same - interfaces for dependency inversion but not for \u0026ldquo;abstracting away\u0026rdquo;.\nIn Go specifically this \u0026ldquo;vanilla\u0026rdquo; Transactor pattern may look a bit verbose, and if your app is doing a lot of transactions, you may want to go a bit further and define your Transactor for example like this:\ntype Transactor interface { // ExecInTransaction executes the given function within a transaction ExecInTransaction(ctx context.Context, fn func(tx *sql.Tx) error) error } // then you would do something like below: func (s *UserService) RegisterUser(ctx context.Context, req RegisterUserRequest) error { return s.transactor.ExecInTransaction(ctx, func(tx *sql.Tx) error { err := s.userRepo.CreateUser(ctx, tx, User{...}) if err != nil { return fmt.Errorf(\u0026#34;failed to create user: %w\u0026#34;, err } // do other transactional work... }) } Generally there are multiple ways to deal with that, just use what fits your language and style best.\nOne more thing to mention though is that I don\u0026rsquo;t create separate types on the infrastructure layer by default and reuse what application layer defines as much as possible. Extending application types with some extra fields that are infrastructure-specific is ok most of the time. As complexity increases, separate data types might be introduced, but it\u0026rsquo;s not a default that I tend to follow.\nAPI Layer # And finally the API layer. As it\u0026rsquo;s name suggest - it is handling protocol concerns—HTTP, gRPC, GraphQL, message queues, whatever your application exposes or consumes. It\u0026rsquo;s the boundary between the outside world and your application logic.\nConceptually even CLI interface to your app fits here. However with CLI specifically I prefer to soften the rules even further. By default - attempt to keep CLI following same rules as other API types (e.g use commands or queries from app). However sometimes there are practical reasons to bend the rules a bit - for example if you have a maintenance CLI command that does some direct database operation for emergency purposes, it\u0026rsquo;s ok to do it directly from CLI layer. Just make sure this is an exception, not a rule.\nOne more thing about CLI, when running them, best to run them in actual environment, but not from your local laptop with configured prod credentials. There are multiple ways of doing that, few things I did in the past that worked for me:\nIn GCP - Cloud Run jobs - you can run a ad-hoc jobs right from the Cloud Console with parameter overrides In K8S + ArgoCD - Argo has Workflows that conceptually are similar to Cloud Run jobs And of course, this is not a \u0026ldquo;hard\u0026rdquo; rule for CLI. There may be cases where the \u0026ldquo;sky is falling\u0026rdquo; and you need to run something from your laptop right now. Depending on your risk tolerance and situation, you may allow that as well.\nThe Mapper Tax\nRemember I mentioned about mapper tax and where I\u0026rsquo;m forcing to pay it. So the API layer is the this place.\nLet me show you a few examples that I\u0026rsquo;ve seen happen many times in real-world projects.\nFirstly, some from the Go world:\ntype User struct { ID string `json:\u0026#34;id\u0026#34;` Name string `json:\u0026#34;name\u0026#34;` Email string `json:\u0026#34;email\u0026#34;` PasswordHash string `json:\u0026#34;-\u0026#34;` // should not be exposed } If you are sharing this User with your API layer and then just using json.Marshal to serialize it, what happens if someone forgets to add the json:\u0026quot;-\u0026quot; tag to the PasswordHash field? You just leaked password hashes to the outside world. Not good\u0026hellip; I\u0026rsquo;ve seen this particular case stay unnoticed for months in a production system.\nSimilar thing may happen in TypeScript. Let\u0026rsquo;s consider some NestJS controller example:\n// Let\u0026#39;s say you have this User entity on application layer type User = { id: string; name: string; email: string; passwordHash: string; // should not be exposed }; // Then you declare your user DTO for API layer (I\u0026#39;ll skip annotations for brevity) class UserDTO { id: string; name: string; email: string; } // So far so good, no password hash is exposed, but let\u0026#39;s see what\u0026#39;s typically happening on controller class UserController { @Get(\u0026#39;/users/:id\u0026#39;) async getUser(@Param(\u0026#39;id\u0026#39;) id: string): Promise\u0026lt;UserDTO\u0026gt; { const user = await this.userService.getUserByID(id); // User and UserDTO are type compatible, so this works, // but leaks passwordHash to the response... return user; } } There are multiple ways to solve it. Typically in Go I would just do an explicit mapper function. In NestJS you may do the same, or use class-transformer approach. Just keep this in mind and instruct your AI agent to follow this rule as well.\nMaintainability is what matters # Over the years, I realized that following any patterns or book advice with extreme dogmatism is a trap. Balancing complexity and your actual needs is the key. It\u0026rsquo;s not really about layers—it\u0026rsquo;s about where complexity lives and what abstractions you actually need right now.\nA good sign that your system is well architected and maintainable is when:\nYou\u0026rsquo;re adding new features without fighting the structure You have good unit test coverage (100%? probably no, but 80-90% is a good target) and tests are fast and reliable. You follow test pyramid principles (most tests are units, some integration, few end-to-end). CI/CD pipeline is fast and reliable. Different components and parts of the system are loosely coupled and easy to understand Production incidents are rare and when they happen, they\u0026rsquo;re easy to debug and fix. In my early career years, I used to write integration tests most of the time, usually spinning up databases or doing complex network mocking (with Wiremock or similar tooling). \u0026ldquo;It tests the real thing!\u0026rdquo; I thought. Sure, it kind of did. It also took 20 minutes to run the test suite. Attempting to test edge cases often meant adding a monstrous setup just to get the data into the right shape. And don\u0026rsquo;t even get me started on flakiness\u0026hellip;.\nNow I think it\u0026rsquo;s the other way around. A big part of my tests are units. Sure I have integration tests, but they\u0026rsquo;re now more isolated, focused, and faster. Most of my e2e tests are smoke or critical path tests. And this is exactly how this architecture emerged. Pain and struggle lead to it, and I use theoretical concepts as a guide but not a strict rule.\nHere\u0026rsquo;s the interesting part I noticed over time: If you practice TDD, this kind of architecture happens naturally (well, maybe with different layers or names of things). You start with a test, realize you need to mock something, and define an interface. As you add more tests, you separate concerns, and the layers or components emerge organically. TDD doesn\u0026rsquo;t force you into this architecture—it guides you toward it because it\u0026rsquo;s the path of least resistance for testable code.\nAnd it doesn\u0026rsquo;t really matter if you use AI or not. AI helps to generate the code, but you control the architecture, you define goals and outcomes. And it will be you who will maintain the system, well at least until we finally reach AGI :).\nWhen to Skip This Architecture # There are cases where I would probably skip this entire structure and do something simpler:\nSimple CRUD APIs: If your service is purely CRUD with no business logic, you might not need an app layer at all. API → Repository is fine. Though honestly, most \u0026ldquo;simple CRUD\u0026rdquo; projects that I\u0026rsquo;ve built grow to include business logic within weeks.\nCLI tools: Well if it\u0026rsquo;s going to be something very complex then probably, but very likely you can keep things simpler.\nIf nanoseconds matter: If you\u0026rsquo;re building something that needs every bit of performance, you probably want to drop all the layers and overhead. However, for most business apps I build (including high-load ones), I\u0026rsquo;ve never seen a noticeable performance impact caused by layers.\nA Side Benefit I Didn\u0026rsquo;t Expect: AI Gets It # I found that AI coding agents understand well structured codebase very well (with some hints, of course). Just describe the architecture briefly in AGENTS.md or ARCHITECTURE.md, and AI follows it, especially if there are examples.\nFinal Thoughts # I\u0026rsquo;ve been practicing this architecture for years, and here\u0026rsquo;s what I keep coming back to: architecture should optimize for the problems you actually have, or at least see on the horizon, not the ones you might have someday.\nWill you swap databases? Probably not. Will you be adding new features or extending existing ones? Very likely yes. Will you need to test your business logic without spinning up Postgres? Absolutely. Do you want your teammates to get up to speed quickly? Of course.\nThis architecture solves those problems. Not perfectly—nothing does—but well enough that I can focus on building features instead of fighting my own structure.\nIf you\u0026rsquo;re building a new backend service, don\u0026rsquo;t start with a flat structure \u0026ldquo;because it\u0026rsquo;s simple.\u0026rdquo; That simplicity is a trap. But also don\u0026rsquo;t start with 12 files per domain entity \u0026ldquo;because Clean Architecture says so.\u0026rdquo;\nYou can always add more abstraction later, but it\u0026rsquo;s usually much harder to remove it once it\u0026rsquo;s there.\nOh, and I almost forgot—I have a Go boilerplate repo that demonstrates all these concepts in practice. Check it out here: Golang Backend Boilerplate.\nFurther Reading # Clean Architecture by Robert C. Martin - The foundational principles. Quite old but still relevant. Hexagonal Architecture - Ports and adapters pattern Domain-Driven Design - Strategic design patterns The Twelve-Factor App - Best practices for modern backend services This post explores architectural principles from a YouTube video where I discuss my backend boilerplate template.\n","date":"14 February 2026","externalUrl":null,"permalink":"/posts/pragmatic-layered-architecture/","section":"Posts","summary":"Most backend architectures optimize for theoretical purity while ignoring real constraints. Learn how pragmatic layered architecture delivers 90% of Clean Architecture’s benefits with 10% of the ceremony—principles that work across any language or framework.","title":"Pragmatic Layered Architecture: Beyond Clean Architecture Dogma","type":"posts"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/categories/software-architecture/","section":"Categories","summary":"","title":"Software Architecture","type":"categories"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/categories/software-design/","section":"Categories","summary":"","title":"Software Design","type":"categories"},{"content":"","date":"14 February 2026","externalUrl":null,"permalink":"/tags/software-design/","section":"Tags","summary":"","title":"Software-Design","type":"tags"},{"content":" Eugene Enjoy coding (both Vibe and sometimes old way) Hi, I\u0026rsquo;m Eugene. In this blog I write various stuff about software engineering and related topics. With many years of experience in the field, I\u0026rsquo;m passionate about sharing my knowledge, what worked for me and what didn\u0026rsquo;t, what I\u0026rsquo;m currently working on\u0026hellip; and maybe something else. I also post interesting findings on my Twitter and sometimes shoot Youtube videos. You will find all links in the above author section.\nIf you have any questions or want to chat about anything, feel free to DM me on Twitter or LinkedIn.\n","externalUrl":null,"permalink":"/content/about/","section":"Contents","summary":"","title":"About","type":"content"},{"content":"","externalUrl":null,"permalink":"/content/","section":"Contents","summary":"","title":"Contents","type":"content"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]