Back to the journal
Go / Architecture

Getting plugin startup order right in Go

How Go Hypercube checks plugin dependencies and separates registration from startup.

In Go Hypercube, adding a plugin and starting it are separate steps. A plugin can provide services that another plugin needs, so the order has to come from those dependencies.

The framework is an evolution of my earlier Go Light Framework. It lives in the Go Hypercube organization, alongside separate cache and queue implementations. In this post, I want to walk through the startup code in the core repository. It is a small enough part to follow, but it touches several decisions that matter once plugins depend on each other.

Describe the dependency in the plugin

A plugin exposes a name, a version and a list of dependencies. It also has Register and Boot methods.

The dependency list names other plugins that the application must already have added. It does not ask the framework to download a Go module or install something at startup.

For example, imagine a billing plugin that needs an auth plugin. Billing declares auth as a dependency. If a reporting plugin also needs billing, the resulting startup order is auth, then billing, then reporting. That order should hold even if the app added the plugins in a different sequence.

The application first checks whether those dependencies are present and whether their versions are compatible. A required version of v1.2.0 accepts an installed v1.4.0, but rejects v1.1.0 and v2.0.0. In this implementation, the installed version must meet the minimum and have the same major version. An empty required version only checks that the dependency exists.

Missing plugins and incompatible versions are collected into one error. I prefer getting the dependency problems together so I can fix the configuration in one pass.

The edge direction matters

The graph records an edge from a plugin to the plugin it depends on. In the example, billing points to auth.

That detail matters when sorting the graph. With edges in that direction, the ordinary topological order would put billing ahead of auth. The initialization code uses the reverse topological order to put the dependency first.

The plugin tests check this with a dependency chain and with independent plugins mixed into the graph. Independent plugins do not need a fixed order between themselves. What matters is that every dependency comes before the plugin that needs it.

There is also the case where two plugins depend on each other. Sorting cannot produce a valid startup order for that. The graph returns a cycle path, and setup fails with the dependency cycle in the error.

Register everything before booting

Once the dependency checks and ordering are done, Setup() walks the plugins and calls Register. Registration can add migrations, commands and seeders, and it gives the plugin access to the service container.

Boot() is a later pass. It walks the ordered plugins again after registration has completed. Calling it before setup returns an error.

This gives the lifecycle a clear point where services can be registered before startup logic begins using them. I can look at a plugin’s two methods and see which work belongs to making its capabilities available and which work belongs to starting them.

There are checks around adding plugins too. Duplicate names are rejected, a failed addition does not leave a partially added group behind, and plugins cannot be added after setup. Those rules keep the set of plugins stable while the application is initializing.

Keep the scope clear

The code is still evolving. I do not treat the existence of a plugin interface as proof that every startup failure has a recovery path. Registration can return an error, boot can return an error, and the application has to handle those outcomes.

For this part of the framework, the behaviour I want to keep explicit is which plugin needs which service, when that service becomes available and why startup stopped. The current sequence is in the application lifecycle and the dependency resolver.