Building a System, Not a Feature
When I started building the ordering system for Dursley Donkey Coffee, I had a choice. I could build the backend first and test it with curl. I could build the app first and mock the API. I could build one feature end-to-end before starting the next. Instead, I built all three layers — Go backend, Next.js admin dashboard, and database tooling — together from the start.
This wasn't about speed, though it did happen quickly. It was about making sure every layer could grow without the others becoming a bottleneck.
The Problem with Building One Thing at a Time
The obvious approach is to start with the backend. Get the API solid, write tests, then build the frontend that consumes it. It's clean, it's sequential, and it's how most tutorials teach it. But it has a real cost.
If I build the backend first and test it only through API calls, I won't discover usability problems until much later. An endpoint might return data in a structure that's technically correct but awkward to render. A status update flow might require three API calls where one would do. These issues only surface when you actually build the UI that uses the API, and by then the backend patterns are established and harder to change.
The same problem works in reverse. If I build the app first against a mocked API, I'll design around assumptions about what the backend can do. When the real backend arrives, the mocks rarely match reality and the app needs reworking.
And if I focus entirely on the backend without an admin dashboard, I can't do real-world testing. I can seed the database, but I can't manage a menu, monitor orders, or see what the system looks like with actual data flowing through it. Issues that would be obvious with a working admin panel — like a missing field or an unclear status flow — stay hidden until much later, when they're more expensive to fix.
What "Building Together" Looks Like
The development of Dursley Donkey Coffee followed a pattern: each round of work touched multiple layers.
The first backend commit established auth, menu CRUD, and order management. The same day, the admin dashboard got its authentication flow, sidebar navigation, and the first portal pages — a dashboard with live stats, an order management table, and a menu editor with full CRUD.
This meant that within hours of writing the menu API, I was using it through a real UI. When the order listing endpoint returned data, I was immediately rendering it in a filterable table. When I added status updates to orders, the admin could change statuses from a dropdown and see the result. Every backend feature was validated through actual use, not just API tests.
The Admin as a Testing Tool
The admin dashboard isn't just a management interface — it's the primary way I test the backend during development. The portal pages exercise the core API: creating menu items, placing orders, updating statuses, generating reports. The stats dashboard aggregates data and immediately shows if something is wrong with the numbers.
But the most powerful testing tool is the generic database browser. Rather than building a custom admin page for every table, I built a single page that can browse, insert, edit, and delete rows in any table:
var allowedTables = map[string]bool{
"users": true,
"menu_items": true,
"orders": true,
"order_items": true,
}
The backend exposes table schemas via PRAGMA table_info() and the frontend dynamically generates forms based on column types. Need to check if a migration ran correctly? Browse the table. Need to fix a test user's role? Edit the row directly. Need to see what a cascade delete will do? The delete preview shows you.
This single feature eliminated the need for a separate database client during development. The admin dashboard is the database client, with the added benefit that it goes through the same auth and validation layers that production requests will use.
Staging as a First-Class Concern
Both projects got staging environment support early. The backend uses APP_ENV to switch between ports and database files:
func LoadConfig() (*Config, error) {
env := os.Getenv("APP_ENV")
if env == "" {
env = "production"
}
switch env {
case "production":
setDefault("PORT", "8080")
setDefault("DB_PATH", "coffee.db")
case "staging":
setDefault("PORT", "8081")
setDefault("DB_PATH", "coffee-staging.db")
}
// ...
}
The admin dashboard has a matching staging mode that runs on a different port and shows an amber warning banner so you always know which environment you're looking at. Running npm run dev connects to production; npm run dev:staging connects to the staging backend.
This means I can run both environments simultaneously. Production data stays clean. Staging can be wiped and reseeded freely. And because both environments exist from day one, there's no painful "add staging support" migration later.
Why This Matters for the Long Term
Dursley Donkey Coffee is being built for a friend's coffee stand, but the goal is a white-label product — a system that can be rebranded and sold to other independent coffee shops. That changes how I think about building it.
If this were a one-off project, I might cut corners on the admin tooling. Build something quick and dirty, manage the database directly, add a proper dashboard later. But for a product that other people will run, the admin dashboard is the product just as much as the customer-facing app. It needs to work well from the start because it's what shop owners will interact with daily.
The generic database browser is a good example. For a single client, it's overkill. But for a product that will eventually manage stock, employees, and tax data across multiple businesses, having a schema-aware database tool built into the admin from day one means I can add tables and have them immediately browsable. The tooling scales with the schema.
The Sequence That Worked
Looking back, the order I built things wasn't arbitrary. Each piece enabled the next:
- Backend auth and core API — establishes the contract
- Admin auth and layout — proves the auth flow works end-to-end
- Portal pages (dashboard, orders, menu) — validates the core API through real use
- Staging support — creates a safe space for testing without risking real data
- Admin database browser — gives full visibility into the database through the app itself
- Cascade-aware deletion — adds safety for destructive operations
Each step built on the previous one. The database browser couldn't exist without the admin layout. The cascade deletion couldn't exist without the database browser. Staging wouldn't be useful without portal pages that exercise the API. The whole thing is a dependency chain where each feature unlocks the next.
Trade-offs
Building in parallel means nothing is fully polished at any given point. The portal pages work but could use better loading states. The database browser handles CRUD but doesn't have pagination. The backend returns good error messages but doesn't have structured logging yet.
For a product that's going to market tomorrow, this would be a problem. For a product that's being built iteratively with a friend who'll be the first user, it's the right trade-off. Every layer is functional, testable, and ready to grow. Nothing is blocked on something else.
There's also a discipline cost. Working across multiple codebases in a single sitting requires context-switching, and it's tempting to go deep on one area and neglect the others. I managed this by keeping each work session focused on a vertical slice — a feature that touches backend and frontend together — rather than going horizontal across one layer.
Lessons Learned
Build the admin dashboard alongside the product, not after it. The admin interface is your best testing tool during development and your users' primary interface in production. Treating it as an afterthought means you're flying blind during the most important phase of development.
Vertical slices beat horizontal layers. Building a complete feature across backend and frontend in one pass catches integration issues immediately. Building the entire backend first just defers those issues to a point where they're harder to fix.
Early staging support costs almost nothing and saves everything. Two environment configs, two npm scripts, an amber banner. That's all it takes, and it means you never have to choose between testing freely and protecting real data.