Wails in Practice #1 Designing a Real Project — What to Build and How to Split It
In the Building Desktop Apps with Wails intro series, you learned the concepts — bindings, runtime integration, builds. But that series’ to-do app kept its data in memory only, a learning skeleton that lost everything on close. This practice series puts flesh on that skeleton, completing a local notes app you can actually install and use across six posts. The first post is design: deciding what to build and drawing the structural boundaries before writing code.
The series runs ten posts in two parts. Part 1 builds a shippable app; Part 2 covers the polish that earns repeat readers.
Part 1 — build and ship the app
- #1 Designing a real project — what to build and how to split it ← this post
- #2 SQLite local database — pure Go, no CGO
- #3 Full-text search and data flow — FTS5 and event-driven updates
- #4 Tray residence and a global shortcut — capture fast from the background
- #5 Signing and notarization — how a shipped app earns trust
- #6 CI/CD automated releases — three-platform delivery with GitHub Actions
Part 2 — polish and repeat readers
- #7 The note editor for real — markdown preview and list UX
- #8 A testing strategy — verifying the service and repository
- #9 Implementing auto-update — delivering a new version safely
- #10 Finishing touches — settings, dark mode, data backup
What we build — a local notes app #
The target is a desktop app that stores markdown notes locally and searches them fast. It is an offline-first tool that stays complete inside your own machine, no cloud, aiming for:
- Create, edit, and delete notes, persisted to a local database (#2)
- Full-text search over title and body (#3)
- Residing in the tray, capturing a new note from anywhere via a global shortcut (#4)
- Distributed as a signed and notarized installer (#5)
- Push a tag and a three-platform release builds itself (#6)
The scope is chosen so a small but real app passes through every element a real app needs: persistence, search, background residence, distribution trust, and automation. The screen is simple — a note list and search box on the left, a single editor on the right, is enough.
Drawing boundaries — three layers #
The first difference between a real app and a learning skeleton is that you do not pile all the logic into a single app struct. The intro series attached methods straight onto the App struct, but as features grow that struct becomes a giant blob that knows about storage, search, and window control all at once. Split into three layers from the start.
| Layer | Role | Example in this series |
|---|---|---|
| Binding layer (App) | Thin entry point exposed to the frontend | App.CreateNote, App.Search |
| Service layer | The actual domain logic | NoteService — validation, search, rules |
| Repository layer | Data persistence | NoteRepository — SQLite access |
The binding layer only takes a frontend request, hands it to the service, and returns the result. Domain logic (reject an empty title, normalize a search term) lives in the service; the code that actually reads and writes the DB lives in the repository. Split this way, you can swap the repository from SQLite to something else while the service stays put, and you can verify the service logic with plain Go tests, no Wails involved.
// App(binding) → NoteService(domain) → NoteRepository(storage)
type App struct {
ctx context.Context
notes *NoteService
}
func (a *App) CreateNote(title, body string) (Note, error) {
// the binding layer only delegates to the service
return a.notes.Create(title, body)
}Dependencies point one way. The binding knows the service, the service knows the repository, but never the reverse. That single rule keeps the structure from collapsing as the app grows.
The domain model — a minimal note #
Define the type that represents a single note first. In practice you always need an identifier and timestamps. List sorting, edit tracking, and later synchronization all build on these fields.
type Note struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}The struct tags json:"..." are the field names on the frontend side, as covered in intro #3. Match them to camelCase so Go’s CreatedAt arrives as createdAt in JavaScript.
Frontend state — what to choose #
The frontend works with the same vanilla stack as the intro series, but a real app grows more state. The list, the selected note, the search term, the content being edited, and the save status all move at once. Here the options diverge.
- Vanilla JS: no dependencies and the smallest bundle. Fine when state is simple, but manual DOM updates get complex fast once the states above intertwine.
- Svelte, Vue, React: bind state and screen automatically. Wails is frontend-agnostic, so anything works, and this series assumes a lightweight component framework to reduce the state-management burden (keeping the code from depending heavily on any one framework).
The core principle is one: the frontend is responsible for screen and input only, and the truth of the data always lives on the Go side. Neither search results nor the note list are managed by the frontend on its own — it asks Go. This way you keep data consistency in one place only.
Summary #
- The practice series completes an offline-first local notes app across six posts, through persistence, search, tray, signing, and CI.
- The first difference from a learning skeleton is layer separation. Split into binding (App), service, and repository, with dependencies pointing one way only.
- Always put an ID and created/updated timestamps in the domain model, and match frontend field names to camelCase with JSON tags.
- The frontend handles only screen and input; the truth of the data lives in Go. When state intertwines, a lightweight component framework beats manual DOM updates.
- Next post implements the repository layer with SQLite, using a pure-Go driver that cross-compiles without CGO.