Structural guards and coding agents

With the use of coding agents, which aren’t quite people, asserting on the required outputs becomes a bit of a chore. My own approach is not to, at least in the measure that I prompt for something that is desired, but expect correction over time. So, how do those corrections happen?

Human intervention, I suppose.

The question of structure

Modularity in some form or another is in part based on the filesystem. If you want to group your code by features, maybe the feature is a top level directory. Maybe you have a more complex system, and then it’s features, integrations, modules. Maybe some of these are runtime driven rather than compile-time.

With modularity comes composition and the only question becomes abstraction or absorption, de-normalization or normalization, grow or prune. Testing strategy is as much part of application development as is developing the thing. Testing the structure itself is a requirement with multiple contributors. How each developer interacts with the codebase should have been optimized long ago, but I think the shift now is to optimize them for LLM use, which also optimizes them for human use.

A flat application structure has the uncomfortable point of usage, where each lookup for us humans requires jumping around too many files, and modularity may have us jumping over too many packages, spaghetti code over many pages of changing requirements laid out poorly. Managing application context with structure is a power move, and I will die on this hill.. Agents or not, I like a clean codebase where things are in their expected places, where progressive discovery is possible and optimized for.

Anything small enough is trivial, and anything big enough is incomprehensible without these context boundaries in place. It’s not like we can join any codebase and be productive, right?

I like systems. I use tooling. Tooling is there to validate SQL schema, tooling is there to generate code from a source of truth, it’s there to build, test, run, collect coverage, release things to a Docker registry and GitHub and so on. All of this tooling is there to prevent human error. My errors. Who’s infallible?

There’s an overflow of linting CLIs for each specific purpose of my development, the concerns I’m trying to ensure. With this introduction, I’ll outline 3 processes that I use. None of them depend on a coding agent (I think), but have been written with coding agent assistance. Each example gives context as to what the tooling ensures.

Large scale refactoring of a Go application

We’ve all been there. We let structure expand as needed, things get sorted into the codebase into the wrong locations, you’re fighting a large number of packages in root scope and lack any system of organisation. A naively correct developer may just do the following:

mkdir -p features integrations internal app
git mv workflows projects artifacts threads features
git mv git docker spotify integrations
git mv assert internal
git mv model config app

Since go imports function on local paths, the result is what we all hate and know from refactoring - a broken build state. How can we address these package movements without having a coding agent correct import paths or you having to do it on your own?

I write splint with which I’ve also taken to absorbing some goimports behaviour, fixing and formatting import paths. While there’s some undesired behaviour on the part of goimports, the splint tool also resolves package names, so as long as the package didn’t rename from “login” to “user”, the expectation is that a single invocation fixes this too.

  • splint fix ./... - one and done

The test subject for this tooling is me, and I found this to be quite the time and token saver. There are some rules that make this work in the best way possible, the golden path:

  1. don’t use an internal/ package in root, keep internal standard library only
  2. go test -c ./... doesn’t fail with duplicate packages

The reason for not having a shared internal package to all the scope is simply because it’s a coupling, and whatever responsibilities lie there should be reduced. The reason why to have a zero-dependency internal package is to carry a portable subset of code - code that can be copied to older releases, updating their functionality, fixing bugs and knowing the subset is compatible to what releases may be supported.

The duplicate packages test is a little bit opinionated. It makes a choice over two directory structures, both pretty fine choices:

model/users
service/users
client/users

In this case, typical for something like gRPC services, the code structure provides the main ways to interact with the service. It gives you a client package to query it remotely, it gives you a model package which takes care of the source of truth, and the implementation of the server. There are benefits and pitfalls to this, but the main benefit is to have both a service build (N) and a monolithic binary combining all services (1).

If you don’t have traffic and are just starting up, a monolithic binary deployment is much simpler to manage than a distributed system, but so it is, requirements change over time. The other directory structure, which the rules prefer is as follows:

users/model
users/client
users/service

I prefer this, the splint tool takes advantage - while the example shows a basic gRPC structure, there are other ways to partition and implement this slice. A layer architecture may have a storage package for interacting with the database directly, and in a single-binary build you can have cross-module couplings.

Another benefit of this structural approach is you can have a users/go.mod if you’d like to scope dependencies to a particular service, users/docker to customize the service build envs, everything there is structured as a stand-alone go app following conventions.

Linting and filesystem structure

A structural convention is formed over time as code gets added. Once you have multiple such package trees as described above, the question becomes how similar are they to one another? How much is required, and how much is particular to the package scope? As mentioned, some conventions form over time, decisions are made, one thing looks much like the one next to it.

I wrote ruleslint with the purpose of asserting what is a required structure. It has a very bare readme I wrote by hand and very little else. It’s my unique attempt at linting folder and file structure, as well as contents.

To give an example:

ruleslint {
  name: "monolithic app structure"

  dir {
    users
    settings
    config
  }

  dir {package} {
    model
    storage
    ?api
    ?service
    ?handlers
    {package}.go

    go.mod {
      require 'github.com/titpetric/platform'
      deny 'gopkg.in/'
      deny 'github.com/redis'
    }
  }

  linters {
    splint ./...
  }
}

The tool lints and reports when some of the filesystem that’s expected isn’t there. There are additional concerns that are solved with the linters block, mainly because I want to use one tool invocation as a mnemonic. The automation workflows get ugly with many tools in the loop.

Package constraints

In the stated rule, a package needs a model and storage packages. These two packages stand apart for various benefits - the model has no couplings and represents the application schema in the package. It’s functions if any can optimize for working with the data model, like Validate() error on types that get read from user input, or a Walk[T any](in []T, func(T)). To state some constraints:

  1. model should never have a sync.Mutex. Allocations are considered exclusive-use,
  2. storage is the only place that allows SQL interactions, all the queries are there

I can assert this with ruleslint today, but I’ve found the flexibility of semgrep rules good:

  linters {
    git ls-files -co --exclude-standard | xargs semgrep scan --metrics=off --quiet --error --config semgrep
  }

So, what is better, scanning the whole tree or just the changed files?

I like the expressiveness of semgrep, not just to find code smells but also to do other refactorings. It has an expressive and powerfull syntax which is also type aware of go code. Particularly in larger codebases where conventions and application composition practices need to be updated, I’ve found it as a good tool to use for finer grained concerns. Scans show you how much is wrong, but the tool also enables LSC (large-scale code) rewrites with ease. The package constraints can be fine grained, bad practices corrected.

For example, I can run splint test ./... that filters out changes to the code structure and runs go test only on the files that changed. I have linters in there that require testing conventions. The question is, at which point does something become a nuissance and requires a new tool.

Summary

My approach for development relies heavily on tooling. Things like docker build, docker compose, gh issue list, git pull --rebase, all the kinds of things a coding agent can run today.

I’ve always relied on writing some of my own tooling, but my concerns have usually been one problem at a time. I always had some modular system in place, and that modularity got extended over time, new concerns were raised and the system as a whole needed extending. What a module was, is past. The process was always this is what is and here’s where we’re going.

I think working with coding agents changed that, at least to the point if I dispatch work, I expect multiple concerns to be met, and I expect these concerns to increase in count over time. What works today, doesn’t account for what comes tomorrow.

It’s my opinion that it’s not possible to guard against a full rewrite on a long enough timeline. With anything, the shifting requirements are usually the reason for a rewrite, may that be code growth or performance budgets. I just wonder why this appreciation for clean code doesn’t lead to more of it.

0 comments

No comments yet.

Sign in to comment.