[{"data":1,"prerenderedAt":922},["ShallowReactive",2],{"blog-modular-go-echo-gorm":3,"blog-post-nav":884},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"body":11,"_type":878,"_id":879,"_source":880,"_file":881,"_stem":882,"_extension":883},"/blog/modular-go-echo-gorm","blog",false,"","A Modular Go Web App Pattern with Echo, GORM and golang-migrate","The repeatable structure I use for small Go APIs: self-contained modules, versioned SQL migrations, and a thin handler layer.","2026-04-05",{"type":12,"children":13,"toc":870},"root",[14,51,58,71,81,86,92,103,239,244,250,278,283,387,438,443,514,533,539,544,582,595,641,646,754,759,765,770,840,853,859,864],{"type":15,"tag":16,"props":17,"children":18},"element","p",{},[19,22,31,33,40,42,49],{"type":20,"value":21},"text","Every time I start a small Go service I find myself reaching for the same shape: ",{"type":15,"tag":23,"props":24,"children":28},"a",{"href":25,"rel":26},"https://echo.labstack.com",[27],"nofollow",[29],{"type":20,"value":30},"Echo",{"type":20,"value":32}," for HTTP, ",{"type":15,"tag":23,"props":34,"children":37},{"href":35,"rel":36},"https://gorm.io",[27],[38],{"type":20,"value":39},"GORM",{"type":20,"value":41}," for the database, and ",{"type":15,"tag":23,"props":43,"children":46},{"href":44,"rel":45},"https://github.com/golang-migrate/migrate",[27],[47],{"type":20,"value":48},"golang-migrate",{"type":20,"value":50}," for schema versioning. I finally sat down and codified it into a pattern I can clone for any new domain. Here's how it's laid out.",{"type":15,"tag":52,"props":53,"children":55},"h2",{"id":54},"the-directory-structure",[56],{"type":20,"value":57},"The directory structure",{"type":15,"tag":16,"props":59,"children":60},{},[61,63,69],{"type":20,"value":62},"The core idea is that each domain entity is a ",{"type":15,"tag":64,"props":65,"children":66},"strong",{},[67],{"type":20,"value":68},"self-contained module",{"type":20,"value":70},". Everything that entity needs (routes, handlers, model, seed data) lives in one folder:",{"type":15,"tag":72,"props":73,"children":75},"pre",{"code":74},"project-root/\n├── main.go              # Entry point\n├── Makefile             # Build, run, migration commands\n├── config/\n│   └── database.go      # DB initialization (global singleton)\n├── migrations/\n│   ├── 000001_create_pokemon.up.sql\n│   └── 000001_create_pokemon.down.sql\n└── modules/\n    ├── routes.go        # Central route registration\n    └── animal/\n        ├── config.go    # Module route + seed registration\n        ├── handler.go   # HTTP handlers\n        ├── animal.go    # GORM model + DB operations\n        └── seed.go      # Idempotent seed data\n",[76],{"type":15,"tag":77,"props":78,"children":79},"code",{"__ignoreMap":7},[80],{"type":20,"value":74},{"type":15,"tag":16,"props":82,"children":83},{},[84],{"type":20,"value":85},"Adding a feature means adding a folder, not threading changes through five different files.",{"type":15,"tag":52,"props":87,"children":89},{"id":88},"the-entry-point",[90],{"type":20,"value":91},"The entry point",{"type":15,"tag":16,"props":93,"children":94},{},[95,101],{"type":15,"tag":77,"props":96,"children":98},{"className":97},[],[99],{"type":20,"value":100},"main.go",{"type":20,"value":102}," stays deliberately boring. Initialise the database, spin up Echo with logging and recovery middleware, register a health check, and hand off to the modules:",{"type":15,"tag":72,"props":104,"children":108},{"code":105,"language":106,"meta":7,"className":107,"style":7},"func main() {\n    config.InitDatabase()\n\n    e := echo.New()\n    e.Use(middleware.Logger())\n    e.Use(middleware.Recover())\n\n    e.GET(\"/health\", func(c echo.Context) error {\n        return c.JSON(200, map[string]string{\"status\": \"healthy\"})\n    })\n\n    modules.RegisterRoutes(e)\n    e.Logger.Fatal(e.Start(\":1323\"))\n}\n","go","language-go shiki shiki-themes github-dark",[109],{"type":15,"tag":77,"props":110,"children":111},{"__ignoreMap":7},[112,123,132,142,151,160,169,177,186,195,204,212,221,230],{"type":15,"tag":113,"props":114,"children":117},"span",{"class":115,"line":116},"line",1,[118],{"type":15,"tag":113,"props":119,"children":120},{},[121],{"type":20,"value":122},"func main() {\n",{"type":15,"tag":113,"props":124,"children":126},{"class":115,"line":125},2,[127],{"type":15,"tag":113,"props":128,"children":129},{},[130],{"type":20,"value":131},"    config.InitDatabase()\n",{"type":15,"tag":113,"props":133,"children":135},{"class":115,"line":134},3,[136],{"type":15,"tag":113,"props":137,"children":139},{"emptyLinePlaceholder":138},true,[140],{"type":20,"value":141},"\n",{"type":15,"tag":113,"props":143,"children":145},{"class":115,"line":144},4,[146],{"type":15,"tag":113,"props":147,"children":148},{},[149],{"type":20,"value":150},"    e := echo.New()\n",{"type":15,"tag":113,"props":152,"children":154},{"class":115,"line":153},5,[155],{"type":15,"tag":113,"props":156,"children":157},{},[158],{"type":20,"value":159},"    e.Use(middleware.Logger())\n",{"type":15,"tag":113,"props":161,"children":163},{"class":115,"line":162},6,[164],{"type":15,"tag":113,"props":165,"children":166},{},[167],{"type":20,"value":168},"    e.Use(middleware.Recover())\n",{"type":15,"tag":113,"props":170,"children":172},{"class":115,"line":171},7,[173],{"type":15,"tag":113,"props":174,"children":175},{"emptyLinePlaceholder":138},[176],{"type":20,"value":141},{"type":15,"tag":113,"props":178,"children":180},{"class":115,"line":179},8,[181],{"type":15,"tag":113,"props":182,"children":183},{},[184],{"type":20,"value":185},"    e.GET(\"/health\", func(c echo.Context) error {\n",{"type":15,"tag":113,"props":187,"children":189},{"class":115,"line":188},9,[190],{"type":15,"tag":113,"props":191,"children":192},{},[193],{"type":20,"value":194},"        return c.JSON(200, map[string]string{\"status\": \"healthy\"})\n",{"type":15,"tag":113,"props":196,"children":198},{"class":115,"line":197},10,[199],{"type":15,"tag":113,"props":200,"children":201},{},[202],{"type":20,"value":203},"    })\n",{"type":15,"tag":113,"props":205,"children":207},{"class":115,"line":206},11,[208],{"type":15,"tag":113,"props":209,"children":210},{"emptyLinePlaceholder":138},[211],{"type":20,"value":141},{"type":15,"tag":113,"props":213,"children":215},{"class":115,"line":214},12,[216],{"type":15,"tag":113,"props":217,"children":218},{},[219],{"type":20,"value":220},"    modules.RegisterRoutes(e)\n",{"type":15,"tag":113,"props":222,"children":224},{"class":115,"line":223},13,[225],{"type":15,"tag":113,"props":226,"children":227},{},[228],{"type":20,"value":229},"    e.Logger.Fatal(e.Start(\":1323\"))\n",{"type":15,"tag":113,"props":231,"children":233},{"class":115,"line":232},14,[234],{"type":15,"tag":113,"props":235,"children":236},{},[237],{"type":20,"value":238},"}\n",{"type":15,"tag":16,"props":240,"children":241},{},[242],{"type":20,"value":243},"The database is a global GORM singleton initialised once at startup. It's not the most testable choice (for that you'd inject the DB as a dependency), but for a small service the simplicity wins.",{"type":15,"tag":52,"props":245,"children":247},{"id":246},"migrations-are-sql-not-automigrate",[248],{"type":20,"value":249},"Migrations are SQL, not AutoMigrate",{"type":15,"tag":16,"props":251,"children":252},{},[253,255,261,263,269,271,276],{"type":20,"value":254},"This is the decision I feel most strongly about. GORM ships with ",{"type":15,"tag":77,"props":256,"children":258},{"className":257},[],[259],{"type":20,"value":260},"AutoMigrate",{"type":20,"value":262},", which inspects your structs and mutates the schema to match. It's convenient until it isn't, there's no clean rollback and no record of ",{"type":15,"tag":264,"props":265,"children":266},"em",{},[267],{"type":20,"value":268},"what",{"type":20,"value":270}," changed ",{"type":15,"tag":264,"props":272,"children":273},{},[274],{"type":20,"value":275},"when",{"type":20,"value":277},".",{"type":15,"tag":16,"props":279,"children":280},{},[281],{"type":20,"value":282},"Instead I keep schema as plain, version-controlled SQL files with matching up/down pairs:",{"type":15,"tag":72,"props":284,"children":288},{"code":285,"language":286,"meta":7,"className":287,"style":7},"CREATE TABLE IF NOT EXISTS animals (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    created_at DATETIME,\n    updated_at DATETIME,\n    deleted_at DATETIME,\n    name TEXT NOT NULL,\n    species TEXT NOT NULL,\n    age INTEGER DEFAULT 0,\n    habitat TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_animals_deleted_at ON animals(deleted_at);\n","sql","language-sql shiki shiki-themes github-dark",[289],{"type":15,"tag":77,"props":290,"children":291},{"__ignoreMap":7},[292,300,308,316,324,332,340,348,356,364,372,379],{"type":15,"tag":113,"props":293,"children":294},{"class":115,"line":116},[295],{"type":15,"tag":113,"props":296,"children":297},{},[298],{"type":20,"value":299},"CREATE TABLE IF NOT EXISTS animals (\n",{"type":15,"tag":113,"props":301,"children":302},{"class":115,"line":125},[303],{"type":15,"tag":113,"props":304,"children":305},{},[306],{"type":20,"value":307},"    id INTEGER PRIMARY KEY AUTOINCREMENT,\n",{"type":15,"tag":113,"props":309,"children":310},{"class":115,"line":134},[311],{"type":15,"tag":113,"props":312,"children":313},{},[314],{"type":20,"value":315},"    created_at DATETIME,\n",{"type":15,"tag":113,"props":317,"children":318},{"class":115,"line":144},[319],{"type":15,"tag":113,"props":320,"children":321},{},[322],{"type":20,"value":323},"    updated_at DATETIME,\n",{"type":15,"tag":113,"props":325,"children":326},{"class":115,"line":153},[327],{"type":15,"tag":113,"props":328,"children":329},{},[330],{"type":20,"value":331},"    deleted_at DATETIME,\n",{"type":15,"tag":113,"props":333,"children":334},{"class":115,"line":162},[335],{"type":15,"tag":113,"props":336,"children":337},{},[338],{"type":20,"value":339},"    name TEXT NOT NULL,\n",{"type":15,"tag":113,"props":341,"children":342},{"class":115,"line":171},[343],{"type":15,"tag":113,"props":344,"children":345},{},[346],{"type":20,"value":347},"    species TEXT NOT NULL,\n",{"type":15,"tag":113,"props":349,"children":350},{"class":115,"line":179},[351],{"type":15,"tag":113,"props":352,"children":353},{},[354],{"type":20,"value":355},"    age INTEGER DEFAULT 0,\n",{"type":15,"tag":113,"props":357,"children":358},{"class":115,"line":188},[359],{"type":15,"tag":113,"props":360,"children":361},{},[362],{"type":20,"value":363},"    habitat TEXT\n",{"type":15,"tag":113,"props":365,"children":366},{"class":115,"line":197},[367],{"type":15,"tag":113,"props":368,"children":369},{},[370],{"type":20,"value":371},");\n",{"type":15,"tag":113,"props":373,"children":374},{"class":115,"line":206},[375],{"type":15,"tag":113,"props":376,"children":377},{"emptyLinePlaceholder":138},[378],{"type":20,"value":141},{"type":15,"tag":113,"props":380,"children":381},{"class":115,"line":214},[382],{"type":15,"tag":113,"props":383,"children":384},{},[385],{"type":20,"value":386},"CREATE INDEX IF NOT EXISTS idx_animals_deleted_at ON animals(deleted_at);\n",{"type":15,"tag":16,"props":388,"children":389},{},[390,392,398,400,406,407,413,415,421,423,428,430,436],{"type":20,"value":391},"Two things to note. The ",{"type":15,"tag":77,"props":393,"children":395},{"className":394},[],[396],{"type":20,"value":397},"created_at",{"type":20,"value":399}," / ",{"type":15,"tag":77,"props":401,"children":403},{"className":402},[],[404],{"type":20,"value":405},"updated_at",{"type":20,"value":399},{"type":15,"tag":77,"props":408,"children":410},{"className":409},[],[411],{"type":20,"value":412},"deleted_at",{"type":20,"value":414}," columns mirror what GORM's embedded ",{"type":15,"tag":77,"props":416,"children":418},{"className":417},[],[419],{"type":20,"value":420},"gorm.Model",{"type":20,"value":422}," expects, and indexing ",{"type":15,"tag":77,"props":424,"children":426},{"className":425},[],[427],{"type":20,"value":412},{"type":20,"value":429}," keeps soft-delete queries fast, GORM appends ",{"type":15,"tag":77,"props":431,"children":433},{"className":432},[],[434],{"type":20,"value":435},"WHERE deleted_at IS NULL",{"type":20,"value":437}," to every read by default.",{"type":15,"tag":16,"props":439,"children":440},{},[441],{"type":20,"value":442},"The Makefile wraps the migrate CLI so I never have to remember the flags:",{"type":15,"tag":72,"props":444,"children":448},{"code":445,"language":446,"meta":7,"className":447,"style":7},"DB_URL=sqlite3://app.db\nMIGRATE=$(shell go env GOPATH)/bin/migrate\n\nmigrate-up:\n    $(MIGRATE) -path migrations -database \"$(DB_URL)\" up\n\nmigrate-create:\n    $(MIGRATE) create -ext sql -dir migrations -seq $(name)\n","makefile","language-makefile shiki shiki-themes github-dark",[449],{"type":15,"tag":77,"props":450,"children":451},{"__ignoreMap":7},[452,460,468,475,483,491,498,506],{"type":15,"tag":113,"props":453,"children":454},{"class":115,"line":116},[455],{"type":15,"tag":113,"props":456,"children":457},{},[458],{"type":20,"value":459},"DB_URL=sqlite3://app.db\n",{"type":15,"tag":113,"props":461,"children":462},{"class":115,"line":125},[463],{"type":15,"tag":113,"props":464,"children":465},{},[466],{"type":20,"value":467},"MIGRATE=$(shell go env GOPATH)/bin/migrate\n",{"type":15,"tag":113,"props":469,"children":470},{"class":115,"line":134},[471],{"type":15,"tag":113,"props":472,"children":473},{"emptyLinePlaceholder":138},[474],{"type":20,"value":141},{"type":15,"tag":113,"props":476,"children":477},{"class":115,"line":144},[478],{"type":15,"tag":113,"props":479,"children":480},{},[481],{"type":20,"value":482},"migrate-up:\n",{"type":15,"tag":113,"props":484,"children":485},{"class":115,"line":153},[486],{"type":15,"tag":113,"props":487,"children":488},{},[489],{"type":20,"value":490},"    $(MIGRATE) -path migrations -database \"$(DB_URL)\" up\n",{"type":15,"tag":113,"props":492,"children":493},{"class":115,"line":162},[494],{"type":15,"tag":113,"props":495,"children":496},{"emptyLinePlaceholder":138},[497],{"type":20,"value":141},{"type":15,"tag":113,"props":499,"children":500},{"class":115,"line":171},[501],{"type":15,"tag":113,"props":502,"children":503},{},[504],{"type":20,"value":505},"migrate-create:\n",{"type":15,"tag":113,"props":507,"children":508},{"class":115,"line":179},[509],{"type":15,"tag":113,"props":510,"children":511},{},[512],{"type":20,"value":513},"    $(MIGRATE) create -ext sql -dir migrations -seq $(name)\n",{"type":15,"tag":16,"props":515,"children":516},{},[517,523,525,531],{"type":15,"tag":77,"props":518,"children":520},{"className":519},[],[521],{"type":20,"value":522},"make migrate-create name=create_widgets",{"type":20,"value":524}," scaffolds a new up/down pair; ",{"type":15,"tag":77,"props":526,"children":528},{"className":527},[],[529],{"type":20,"value":530},"make migrate-up",{"type":20,"value":532}," applies everything pending.",{"type":15,"tag":52,"props":534,"children":536},{"id":535},"the-module-pattern",[537],{"type":20,"value":538},"The module pattern",{"type":15,"tag":16,"props":540,"children":541},{},[542],{"type":20,"value":543},"A central hub registers each module, so wiring up a new one is a single line:",{"type":15,"tag":72,"props":545,"children":547},{"code":546,"language":106,"meta":7,"className":107,"style":7},"func RegisterRoutes(e *echo.Echo) {\n    animal.Register(e.Group(\"/animals\"))\n    // Add new modules here\n}\n",[548],{"type":15,"tag":77,"props":549,"children":550},{"__ignoreMap":7},[551,559,567,575],{"type":15,"tag":113,"props":552,"children":553},{"class":115,"line":116},[554],{"type":15,"tag":113,"props":555,"children":556},{},[557],{"type":20,"value":558},"func RegisterRoutes(e *echo.Echo) {\n",{"type":15,"tag":113,"props":560,"children":561},{"class":115,"line":125},[562],{"type":15,"tag":113,"props":563,"children":564},{},[565],{"type":20,"value":566},"    animal.Register(e.Group(\"/animals\"))\n",{"type":15,"tag":113,"props":568,"children":569},{"class":115,"line":134},[570],{"type":15,"tag":113,"props":571,"children":572},{},[573],{"type":20,"value":574},"    // Add new modules here\n",{"type":15,"tag":113,"props":576,"children":577},{"class":115,"line":144},[578],{"type":15,"tag":113,"props":579,"children":580},{},[581],{"type":20,"value":238},{"type":15,"tag":16,"props":583,"children":584},{},[585,587,593],{"type":20,"value":586},"Each module's ",{"type":15,"tag":77,"props":588,"children":590},{"className":589},[],[591],{"type":20,"value":592},"config.go",{"type":20,"value":594}," seeds its data and declares its routes:",{"type":15,"tag":72,"props":596,"children":598},{"code":597,"language":106,"meta":7,"className":107,"style":7},"func Register(g *echo.Group) {\n    seed()\n    g.GET(\"\", getAll)\n    g.GET(\"/:id\", getByID)\n}\n",[599],{"type":15,"tag":77,"props":600,"children":601},{"__ignoreMap":7},[602,610,618,626,634],{"type":15,"tag":113,"props":603,"children":604},{"class":115,"line":116},[605],{"type":15,"tag":113,"props":606,"children":607},{},[608],{"type":20,"value":609},"func Register(g *echo.Group) {\n",{"type":15,"tag":113,"props":611,"children":612},{"class":115,"line":125},[613],{"type":15,"tag":113,"props":614,"children":615},{},[616],{"type":20,"value":617},"    seed()\n",{"type":15,"tag":113,"props":619,"children":620},{"class":115,"line":134},[621],{"type":15,"tag":113,"props":622,"children":623},{},[624],{"type":20,"value":625},"    g.GET(\"\", getAll)\n",{"type":15,"tag":113,"props":627,"children":628},{"class":115,"line":144},[629],{"type":15,"tag":113,"props":630,"children":631},{},[632],{"type":20,"value":633},"    g.GET(\"/:id\", getByID)\n",{"type":15,"tag":113,"props":635,"children":636},{"class":115,"line":153},[637],{"type":15,"tag":113,"props":638,"children":639},{},[640],{"type":20,"value":238},{"type":15,"tag":16,"props":642,"children":643},{},[644],{"type":20,"value":645},"The model file holds the GORM struct and all database operations, keeping data access in one place:",{"type":15,"tag":72,"props":647,"children":649},{"code":648,"language":106,"meta":7,"className":107,"style":7},"type Animal struct {\n    gorm.Model\n    Name    string `json:\"name\"`\n    Species string `json:\"species\"`\n    Age     int    `json:\"age\"`\n    Habitat string `json:\"habitat\"`\n}\n\nfunc GetAll() ([]Animal, error) {\n    var animals []Animal\n    result := config.DB.Find(&animals)\n    return animals, result.Error\n}\n",[650],{"type":15,"tag":77,"props":651,"children":652},{"__ignoreMap":7},[653,661,669,677,685,693,701,708,715,723,731,739,747],{"type":15,"tag":113,"props":654,"children":655},{"class":115,"line":116},[656],{"type":15,"tag":113,"props":657,"children":658},{},[659],{"type":20,"value":660},"type Animal struct {\n",{"type":15,"tag":113,"props":662,"children":663},{"class":115,"line":125},[664],{"type":15,"tag":113,"props":665,"children":666},{},[667],{"type":20,"value":668},"    gorm.Model\n",{"type":15,"tag":113,"props":670,"children":671},{"class":115,"line":134},[672],{"type":15,"tag":113,"props":673,"children":674},{},[675],{"type":20,"value":676},"    Name    string `json:\"name\"`\n",{"type":15,"tag":113,"props":678,"children":679},{"class":115,"line":144},[680],{"type":15,"tag":113,"props":681,"children":682},{},[683],{"type":20,"value":684},"    Species string `json:\"species\"`\n",{"type":15,"tag":113,"props":686,"children":687},{"class":115,"line":153},[688],{"type":15,"tag":113,"props":689,"children":690},{},[691],{"type":20,"value":692},"    Age     int    `json:\"age\"`\n",{"type":15,"tag":113,"props":694,"children":695},{"class":115,"line":162},[696],{"type":15,"tag":113,"props":697,"children":698},{},[699],{"type":20,"value":700},"    Habitat string `json:\"habitat\"`\n",{"type":15,"tag":113,"props":702,"children":703},{"class":115,"line":171},[704],{"type":15,"tag":113,"props":705,"children":706},{},[707],{"type":20,"value":238},{"type":15,"tag":113,"props":709,"children":710},{"class":115,"line":179},[711],{"type":15,"tag":113,"props":712,"children":713},{"emptyLinePlaceholder":138},[714],{"type":20,"value":141},{"type":15,"tag":113,"props":716,"children":717},{"class":115,"line":188},[718],{"type":15,"tag":113,"props":719,"children":720},{},[721],{"type":20,"value":722},"func GetAll() ([]Animal, error) {\n",{"type":15,"tag":113,"props":724,"children":725},{"class":115,"line":197},[726],{"type":15,"tag":113,"props":727,"children":728},{},[729],{"type":20,"value":730},"    var animals []Animal\n",{"type":15,"tag":113,"props":732,"children":733},{"class":115,"line":206},[734],{"type":15,"tag":113,"props":735,"children":736},{},[737],{"type":20,"value":738},"    result := config.DB.Find(&animals)\n",{"type":15,"tag":113,"props":740,"children":741},{"class":115,"line":214},[742],{"type":15,"tag":113,"props":743,"children":744},{},[745],{"type":20,"value":746},"    return animals, result.Error\n",{"type":15,"tag":113,"props":748,"children":749},{"class":115,"line":223},[750],{"type":15,"tag":113,"props":751,"children":752},{},[753],{"type":20,"value":238},{"type":15,"tag":16,"props":755,"children":756},{},[757],{"type":20,"value":758},"Handlers stay thin, bind input, call the model, return JSON. There's no service layer, and that's intentional. When a handler is just CRUD, a service layer is ceremony. I'll add one the moment business logic outgrows simple reads and writes, and not a minute before.",{"type":15,"tag":52,"props":760,"children":762},{"id":761},"idempotent-seeding",[763],{"type":20,"value":764},"Idempotent seeding",{"type":15,"tag":16,"props":766,"children":767},{},[768],{"type":20,"value":769},"Seeds run on every startup but only do work once:",{"type":15,"tag":72,"props":771,"children":773},{"code":772,"language":106,"meta":7,"className":107,"style":7},"func seed() {\n    var count int64\n    config.DB.Model(&Animal{}).Count(&count)\n    if count > 0 {\n        return\n    }\n    // ... insert seed rows\n}\n",[774],{"type":15,"tag":77,"props":775,"children":776},{"__ignoreMap":7},[777,785,793,801,809,817,825,833],{"type":15,"tag":113,"props":778,"children":779},{"class":115,"line":116},[780],{"type":15,"tag":113,"props":781,"children":782},{},[783],{"type":20,"value":784},"func seed() {\n",{"type":15,"tag":113,"props":786,"children":787},{"class":115,"line":125},[788],{"type":15,"tag":113,"props":789,"children":790},{},[791],{"type":20,"value":792},"    var count int64\n",{"type":15,"tag":113,"props":794,"children":795},{"class":115,"line":134},[796],{"type":15,"tag":113,"props":797,"children":798},{},[799],{"type":20,"value":800},"    config.DB.Model(&Animal{}).Count(&count)\n",{"type":15,"tag":113,"props":802,"children":803},{"class":115,"line":144},[804],{"type":15,"tag":113,"props":805,"children":806},{},[807],{"type":20,"value":808},"    if count > 0 {\n",{"type":15,"tag":113,"props":810,"children":811},{"class":115,"line":153},[812],{"type":15,"tag":113,"props":813,"children":814},{},[815],{"type":20,"value":816},"        return\n",{"type":15,"tag":113,"props":818,"children":819},{"class":115,"line":162},[820],{"type":15,"tag":113,"props":821,"children":822},{},[823],{"type":20,"value":824},"    }\n",{"type":15,"tag":113,"props":826,"children":827},{"class":115,"line":171},[828],{"type":15,"tag":113,"props":829,"children":830},{},[831],{"type":20,"value":832},"    // ... insert seed rows\n",{"type":15,"tag":113,"props":834,"children":835},{"class":115,"line":179},[836],{"type":15,"tag":113,"props":837,"children":838},{},[839],{"type":20,"value":238},{"type":15,"tag":16,"props":841,"children":842},{},[843,845,851],{"type":20,"value":844},"This means a fresh clone of the repo plus ",{"type":15,"tag":77,"props":846,"children":848},{"className":847},[],[849],{"type":20,"value":850},"make migrate-up && make run",{"type":20,"value":852}," gives you a working, populated API with zero manual steps.",{"type":15,"tag":52,"props":854,"children":856},{"id":855},"why-bother-codifying-it",[857],{"type":20,"value":858},"Why bother codifying it",{"type":15,"tag":16,"props":860,"children":861},{},[862],{"type":20,"value":863},"The point of pinning this down isn't the pattern itself, it's that I stop re-litigating these decisions every time. SQLite is the default driver here, but because data access is isolated in model files, swapping in Postgres is a config change rather than a rewrite. Adding a new entity is a five-step checklist: migration, SQL, run it, create the module, register it. That predictability is the whole value.",{"type":15,"tag":865,"props":866,"children":867},"style",{},[868],{"type":20,"value":869},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":125,"depth":125,"links":871},[872,873,874,875,876,877],{"id":54,"depth":125,"text":57},{"id":88,"depth":125,"text":91},{"id":246,"depth":125,"text":249},{"id":535,"depth":125,"text":538},{"id":761,"depth":125,"text":764},{"id":855,"depth":125,"text":858},"markdown","content:blog:modular-go-echo-gorm.md","content","blog/modular-go-echo-gorm.md","blog/modular-go-echo-gorm","md",[885,889,893,897,901,905,909,910,914,918],{"_path":886,"title":887,"date":888},"/blog/deploying-nuxt-to-cloudflare-workers","Deploying this Nuxt site to Cloudflare Workers","2026-06-06",{"_path":890,"title":891,"date":892},"/blog/building-forever-llm","Building forever-llm: three takes on a model that never stops","2026-05-20",{"_path":894,"title":895,"date":896},"/blog/laravel-cortex-adhd-productivity","Building Cortex: An ADHD Productivity App in Laravel + Inertia","2026-05-12",{"_path":898,"title":899,"date":900},"/blog/eink-spotify-weather-clock","Building an E-Ink Clock That Shows Spotify, Weather and the Time","2026-05-05",{"_path":902,"title":903,"date":904},"/blog/hetzner-k8s-cluster","Building a K3s Cluster on Hetzner with Terraform and GitOps","2026-04-20",{"_path":906,"title":907,"date":908},"/blog/arduino-uno-q-forza-rev-gauge","A Forza Rev Gauge on the Arduino UNO Q's LED Matrix","2026-04-15",{"_path":4,"title":8,"date":10},{"_path":911,"title":912,"date":913},"/blog/self-hosted-ios-web-push","Self-hosting iOS push notifications with Web Push and PWAs","2026-03-25",{"_path":915,"title":916,"date":917},"/blog/lit-web-components-astro-docs","Building a framework-free web component library with Lit and Astro","2026-03-10",{"_path":919,"title":920,"date":921},"/blog/welcome-to-my-blog","About this site","2024-01-15",1781294950597]