Seamless Go Code Navigation in Hexagonal Architectures with VS Code
Sep 28, 2025golang
When working with Go projects that follow a hexagonal architecture, code navigation can quickly become cumbersome. Between interfaces, adapters, mocks, and middlewares, the built-in Go to Definition (F12
) and Go to Implementations (Ctrl+F12
) commands in Visual Studio Code force you to constantly switch mental context and manually pick the correct target.
This friction gets worse in projects where you generate mocks (e.g. with mockery) or wrapper code for logging and metrics. Every interface ends up with several implementations, and the navigation menu is cluttered with entries that are usually not the one you want.
To solve this, I built a macro for the macro-commander Visual Studio Code Extension that makes navigating Go code seamless and predictable. With this macro bound to F12
, I can jump to the correct implementation or definition in one keystroke—without thinking about whether to use definition or implementation and without manually filtering through mocks or generated wrappers.
The Macro
Here’s the macro logic in plain JavaScript with inline comments for clarity:
// First, try to get the definition of the symbol under the cursor.
const definitions = await vscode.commands.executeCommand(
'vscode.executeDefinitionProvider',
vscode.window.activeTextEditor.document.uri,
vscode.window.activeTextEditor.selection.active
);
if (definitions.length === 1) {
await vscode.commands.executeCommand('editor.action.revealDefinition');
return;
}
// Get all possible implementations of the symbol under the cursor.
const items = await vscode.commands.executeCommand(
'vscode.executeImplementationProvider',
vscode.window.activeTextEditor.document.uri,
vscode.window.activeTextEditor.selection.active
);
if (items.length === 1) {
// If there’s only one implementation, jump directly to it.
await vscode.commands.executeCommand('editor.action.goToImplementation');
return;
}
if (items.length > 1) {
// If there are multiple implementations, filter them.
// Filter out mocks, middlewares, and generated code (_gen.go).
const filtered = items.filter(i => {
if (
i.uri.path.includes('mock') ||
i.uri.path.includes('middleware') ||
i.uri.path.includes('_gen')
) {
return false;
}
return true;
});
if (filtered.length === 1) {
// If only one candidate remains after filtering, open it directly.
const doc = await vscode.workspace.openTextDocument(filtered[0].uri);
await vscode.window.showTextDocument(doc, { selection: filtered[0].range });
return;
}
// Otherwise, show a quick pick menu to choose from filtered results.
// This usually happens when there are multiple real implementations.
// Example: multiple adapters implementing the same interface.
// The user can then pick the desired one.
// The quick pick shows the filename and line number for clarity.
// Example label: "repo.go:10"
const picked = await vscode.window.showQuickPick(
filtered.map(i => {
return {
label: i.uri.path.split('/').pop() + ':' + (i.range.start.line + 1),
description: i.uri.path,
item: i
};
}),
{ placeHolder: 'Multiple implementations found, please pick one' }
);
if (picked) {
const doc = await vscode.workspace.openTextDocument(picked.item.uri);
await vscode.window.showTextDocument(doc, { selection: picked.item.range });
return;
}
}
This filtering logic excludes mocks, middlewares, and generated files (_gen.go
). In most cases, this leaves exactly the one implementation I care about, and I jump there directly.
If you follow a different naming pattern in your project, you can adapt the filtering logic to your needs.
Although this blog uses Go as an example, the macro itself works with any language supported by LSP in VS Code.
Let’s see how this plays out in a simple hexagonal Go project.
Example: Hexagonal Architecture in Go
To see why this macro is a game-changer, let’s walk through a simple hexagonal architecture in Go.
In hexagonal architecture, the service defines interfaces for the methods it offers as well as for the functionality it requires (ports). The service API is consumed by the driving adapters (e.g. http handlers), and the service calls the driven adapters through the ports.
I normally use the following project structure:
/
├── cmd
│ └── myapp
│ └── main.go
└── internal
├── api
│ └── myservice_handlers.go
└── myservice
├── adapters
│ ├── mock
│ │ └── repo_gen.go (omitted for brevity)
│ ├── middleware
│ │ └── repo_log_gen.go (omitted for brevity)
│ └── repo
│ └── repo.go
├── mock
│ └── service_mock_gen.go (omitted for brevity)
├── middleware
│ └── service_log_gen.go (omitted for brevity)
├── myservice_ports.go
└── myservice_service.go
Service Interface (myservice_ports.go
)
package myservice
type RepoPort interface {
GetValue() string
}
type Service interface {
FetchValue() string
}
Service Implementation (myservice_service.go
)
package myservice
type service struct {
repo RepoPort
}
func NewService(r RepoPort) Service {
return &service{repo: r}
}
func (s *service) FetchValue() string {
return s.repo.GetValue()
}
Adapter (repo.go
)
package repo
type Repo struct{}
func NewRepo() *Repo {
return &Repo{}
}
func (r *Repo) GetValue() string {
return "constant-value"
}
HTTP Handler (myservice_handlers.go
)
package api
import (
"fmt"
"net/http"
"github.com/example/myapp/internal/myservice"
serviceMiddleware "github.com/example/myapp/internal/myservice/middleware"
"github.com/example/myapp/internal/myservice/adapters/repo"
adapterMiddleware "github.com/example/myapp/internal/myservice/adapters/middleware"
)
func NewHandler() http.HandlerFunc {
repo := repo.NewRepo()
repoWithLogging := adapterMiddleware.NewRepoWithLogging(repo) // wrapped with logging middleware
svc := myservice.NewService(repoWithLogging)
svcWithLogging := serviceMiddleware.NewServiceWithLogging(svc) // wrapped with logging middleware
return func(w http.ResponseWriter, req *http.Request) {
value := svcWithLogging.FetchValue()
fmt.Fprintln(w, value)
}
}
Main (main.go
)
package main
import (
"log"
"net/http"
"github.com/example/myapp/internal/api"
)
func main() {
http.Handle("/value", api.NewHandler())
log.Fatal(http.ListenAndServe(":8080", nil))
}
Navigation Without the Macro
- From
myservice_handlers.go
, pressingF12
onFetchValue
takes you to the interface, not the implementation. - From the service, pressing
Ctrl+F12
shows several adapters (repo, mocks, middlewares, etc.), and you need to pick the right one manually.
This gets old fast.
Navigation With the Macro
- From the handler → jump directly to the service implementation.
- From the service → jump directly to the correct adapter (skipping mocks and wrappers).
No mental overhead. No extra keystrokes. Just smooth navigation.
Get Started
- Install the macro-commander extension in Visual Studio Code.
- Define the macro in your
settings.json
file as shown below. - Bind the macro to
F12
(or your preferred key) in your keybindings.
settings.json Configuration
Below is the complete macro definition, ready to be copied into your settings.json
:
{
"macros": {
"gotoPreferredImplementation": [
{
"javascript": [
"const definitions = await vscode.commands.executeCommand('vscode.executeDefinitionProvider', vscode.window.activeTextEditor.document.uri, vscode.window.activeTextEditor.selection.active);",
"if (definitions.length === 1) {",
" await vscode.commands.executeCommand('editor.action.revealDefinition');",
" return;",
"}",
"const items = await vscode.commands.executeCommand('vscode.executeImplementationProvider', vscode.window.activeTextEditor.document.uri, vscode.window.activeTextEditor.selection.active);",
"if (items.length === 1) {",
" await vscode.commands.executeCommand('editor.action.goToImplementation');",
" return;",
"}",
"if (items.length > 1) {",
" const filtered = items.filter(i => {",
" if (i.uri.path.includes('mock') || i.uri.path.includes('middleware') || i.uri.path.includes('_gen')) {",
" return false;",
" }",
" return true;",
" });",
" if (filtered.length === 1) {",
" const doc = await vscode.workspace.openTextDocument(filtered[0].uri);",
" await vscode.window.showTextDocument(doc, { selection: filtered[0].range });",
" return;",
" }",
" const picked = await vscode.window.showQuickPick(filtered.map(i => {",
" return {",
" label: i.uri.path.split('/').pop() + ':' + (i.range.start.line + 1),",
" description: i.uri.path,",
" item: i",
" };",
" }), { placeHolder: 'Multiple implementations found, please pick one' });",
" if (picked) {",
" const doc = await vscode.workspace.openTextDocument(picked.item.uri);",
" await vscode.window.showTextDocument(doc, { selection: picked.item.range });",
" return;",
" }",
"}",
]
}
]
}
}
Setting up the Keybinding
Once you’ve defined the macro, bind it to F12
(or your preferred key) by adding the following to your keybindings.json
:
[
{
"key": "f12",
"command": "macros.gotoPreferredImplementation",
"when": "editorHasDefinitionProvider && editorTextFocus && !isInEmbeddedEditor"
}
]
Be aware, that this overwrites the default Go to Definition
command, if you stick to F12
. But this is not a problem, since the macro unifies both definition and implementation jumps into a single shortcut.
Wrap-up
By combining macro-commander (Github) with the LSP functionality provided by the standard Go extension, along with some simple filtering, you can make code navigation in VS Code fit your architecture and workflow. For me, working with Go and hexagonal architectures, this means I can move seamlessly between handlers, services, and adapters without getting lost in generated code.
If your project follows different patterns (e.g., other naming conventions for generated files) or if you are using for example gRPC, just tweak the filtering logic to match. The concept remains the same: make navigation work for your codebase, not the other way around.
Happy coding! 🚀
Update History
- 2025-09-28: Initial release
- 2025-09-29: Refined macro logic, prefer definitions over implementations