A History of Service Weaver's Core API
In this blog post, we explore the history of Service Weaver's core components API. The API has steadily evolved since the inception of Service Weaver over two years ago.
- September 30, 2021: In the Beginning...
- November 10, 2021: Generate Object Interfaces
- December 14, 2021: Replace weaver.Run With weaver.Init
- July 26, 2022: Introduce weaver.Get[T]
- September 6, 2022: Rename Objects to Components
- September 7, 2022: Don't Generate Component Interfaces
- September 9, 2022: Replace Comments With Constructor Arguments
- September 13, 2022: Replace Comments With weaverMake
- January 11, 2023: Replace Constructors With weaver.Implements[T]
- March 1, 2023: Open Source Release
- April 25, 2023: Replace weaver.Init With weaver.Run
- April 26, 2023: Replace weaver.Get With weaver.Ref
- May 30, 2023: Introduce Main Method
- June 21, 2023: Remove Main Method
We hope that by studying the API's history, you gain a better understanding of
- why the API is the way it is,
- how it differs from alternative APIs, and
- what the API might look like in the future.
We conclude with some lessons learned on API design.
September 30, 2021: In the Beginning...
Originally, components were called objects. You defined the API and
implementation of an object using an interface and struct, like you do with
components today. You also had to define a constructor function that returned an
instance of the object. In order for weaver generate
to identify these
constructor functions, you annotated them with //weaver:object
comments,
something inspired by //go:generate
and
//go:build
comments. These //weaver:object
comments also
included the name of the object and some configuration options. Here's an
excerpt from a todo app, one of our earliest example applications:
type App interface {
Add(context.Context, string) (ItemID, error)
List(context.Context) ([]Item, error)
Done(context.Context, ItemID) error
}
type impl struct {
store Store
}
//weaver:object Todo [replicated]
func newTodo(context.Context, weaver.Object) (App, error) {
return &impl{store: MemStore.Get()}, nil
}
func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }
The //weaver:object Todo [replicated]
comment indicates that newTodo
returns
an instance of the Todo
object, which can be replicated. Notice that the
object interface App
, object implementation impl
, and object Todo
all have
different names. Not pictured above is a MemStore
component with a Store
interface. The Todo
component gets a handle to the MemStore
component by
calling MemStore.Get()
, a function generated by weaver generate
.
Here's main.go
.
func main() {
flag.Parse()
config := weaver.Config{
Deployers: []weaver.Deployer{multiprocess.New(), gke.New(gke.Config{})},
}
ctx := context.Background()
weaver.Run(ctx, config, func() {
srv := newServer(Todo.Get())
if err := srv.run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
})
}
To run a Service Weaver app, you called weaver.Run
, passing it a
weaver.Config
and a function to run. A weaver.Config
contained information
about how the application could be deployed. It was only much later that we
formalized this abstraction as a deployer.
November 10, 2021: Generate Object Interfaces
Having to think of three separate names for an object (e.g., App
, impl
,
Todo
) was annoying. Plus, writing the object interface was redundant, we
thought. So, we removed the need to write object interfaces. Instead an object
constructor returned an instance of the object directly (e.g., *impl
) and
weaver generate
generated the appropriate interface from the exported methods
defined on the returned implementation. The todo app again:
type impl struct {
store MemStore
}
//weaver:object Todo [replicated]
func newTodo(context.Context, weaver.Object) (*impl, error) {
return &impl{store: GetMemStore()}, nil
}
func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }
Notice that the App
interface is gone, and newTodo
returns an *impl
.
weaver generate
generates a Todo
interface with Add
, List
, and Done
methods. MemStore.Get()
was also renamed GetMemStore()
.
December 14, 2021: Replace weaver.Run With weaver.Init
Having to pass a function to weaver.Run
was annoying, so we replaced
weaver.Run
with weaver.Init
which returned a root component. We also removed
weaver.Config
. Here's main.go
:
func main() {
flag.Parse()
ctx := context.Background()
root := weaver.Init(ctx)
srv := newServer(GetTodo(root))
if err := srv.run(root); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
July 26, 2022: Introduce weaver.Get[T]
We replaced generated object getters like GetMemStore
with a generic
weaver.Get[T]
function (e.g., weaver.Get[MemStore]
). Go 1.18 introduced
generics only a couple months prior.
September 6, 2022: Rename Objects to Components
We felt the name "object" was a bit confusing. We considered a bunch of names including "module", "actor", "service", and "grain" before ultimately settling on "component".
September 7, 2022: Don't Generate Component Interfaces
We realized that component interfaces, though a bit redundant, were clear and explicit, so we stopped generating them and again required the programmer to write them. We also realized that the interface name and component name could be the same. Here's the todo app again:
type Todo interface {
Add(context.Context, string) (ItemID, error)
List(context.Context) ([]Item, error)
Done(context.Context, ItemID) error
}
type impl struct {
store MemStore
}
//weaver:component Todo [replicated]
func newTodo(context.Context, weaver.Component) (Todo, error) {
return &impl{store: weaver.Get[MemStore]()}, nil
}
func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }
Additionally, this change allowed you to write and build an application without
having to run weaver generate
as often.
September 9, 2022: Replace Comments With Constructor Arguments
Annotating constructor functions with //weaver:component
comments was very
error-prone. If you misspelled the annotation (//waevar:component
) or even
misformatted the annotation (// weaver:component
), weaver generate
would
silently ignore the comment, leaving you scratching your head why your
perfectly valid component wasn't working. This prompted us to remove comment
based annotations and replace them with something the Go compiler could validate
for us.
As a first step, we removed properties like replicated
from comments and
replaced them with constructor arguments. Consider again the Todo
constructor:
//weaver:component Todo
func newTodo(context.Context, weaver.Component, weaver.Replicated) (Todo, error) {
return &impl{store: weaver.Get[MemStore]()}, nil
}
//weaver:component Todo
is now missing the [replicated]
property. Instead,
the newTodo
function receives a weaver.Replicated
argument, signalling that
the Todo
component can be replicated. Around this time, we also introduced
other properties like weaver.Router
for routing. Now that
properties were first class entities in Go, the Go compiler would ensure you did
not misspell or misformat them.
It was also around this time that we removed the weaver.Replicated
property
entirely. We realized that it was impossible to guarantee that a component was
never replicated while still preserving the availability of an application.
September 13, 2022: Replace Comments With weaverMake
We removed comment based annotations entirely! Component constructor functions
were now required to begin with weaverMake
. We also starting using the name of
the interface as the name of the component. Here's Todo
again:
func weaverMakeTodo(context.Context, weaver.Component) (Todo, error) {
return &impl{store: weaver.Get[MemStore]()}, nil
}
Requiring a constructor function to begin with weaverMake
paralleled some
other naming requirements in Go:
- Exported symbols are required to start with a capital letter.
- Test files are required to end in
_test.go
. - Test, benchmark, and fuzz functions are required to begin with
Test
,Benchmark
, andFuzz
.
January 11, 2023: Replace Constructors With weaver.Implements[T]
It was still possible to misspell weaverMake
and have weaver generate
silently ignore a component. We also felt that specifying component properties,
like weaver.Router
, as constructor arguments was a bit confusing. So, we threw
out constructor functions entirely and replaced them with
weaver.Implements[T]
, the API we still use today. Here's the todo app again
(though by this point we had deleted the todo app):
type Todo interface {
Add(context.Context, string) (ItemID, error)
List(context.Context) ([]Item, error)
Done(context.Context, ItemID) error
}
type impl struct {
weaver.Implements[Todo]
store MemStore
}
func (i *impl) Init(context.Context) error {
i.store = weaver.Get[MemStore]()
}
func (*impl) Add(context.Context, string) (ItemID, error) { ... }
func (*impl) List(context.Context) ([]Item, error) { ... }
func (*impl) Done(context.Context, ItemID) error { ... }
Note that impl
embeds weaver.Implements[Todo]
to signal that it implements
the Todo
component. Instead of calling a constructor function, the Service
Weaver runtime instead instantiated and initialized a component by calling the
Init
method.
We also introduced weaver.WithConfig[T]
and weaver.WithRouter[T]
to replace
constructor arguments:
type impl struct {
weaver.Implements[Todo]
weaver.WithConfig[config]
weaver.WithRouter[router]
store MemStore
}
March 1, 2023: Open Source Release
We open sourced Service Weaver! 🎉
April 25, 2023: Replace weaver.Init With weaver.Run
Every Service Weaver application had a main component, the one returned by
weaver.Init
. But unlike every other component, the main component was
implicit. The user didn't define the main component; it just... existed. We
decided to concretize things by having the programmer explicitly define the main
component. To do so, we replaced weaver.Init
with weaver.Run
, reverting a
decision we made over a year prior. Here's the todo app again:
func main() {
flag.Parse()
if err := weaver.Run(context.Background(), serve); err != nil {
log.Fatal(err)
}
}
type app struct {
weaver.Implements[weaver.Main]
todo Todo
}
func (a *app) Init(context.Context) error {
a.todo = weaver.Get[Todo]()
}
func serve(ctx context.Context, app *app) {
// ...
}
This API is what we have today. weaver.Run
takes in a function that receives
an instance of the main component, app
in this case. app
implements the main
component, indicated by its embedded weaver.Implements[weaver.Main]
.
weaver.Main
is the trivial empty interface:
type Main interface {}
April 26, 2023: Replace weaver.Get With weaver.Ref
At this point, any component could call methods on any other component by
calling weaver.Get[T]
. This had two downsides:
- It was confusing. It was hard to understand how messages flowed through your application because any component could call a method on any other component at any time.
- It was not secure. If one component was hacked, it had free reign to contact all other components.
To address this, we began an initiative to restrict the set of methods a
component could call. An initial idea was to have a programmer enumerate, in a
config file, every single component in their app and specify which other
components it could communicate with. If component A
called weaver.Get[B]
,
the call would fail if component A
was not authorized to call methods on
component B
. This approach had a number of problems. Most notably, it was
extremely cumbersome.
Then, we realized we could declare the component call graph directly in the
application code itself. To do so, we removed weaver.Get
and replaced it with
weaver.Ref[T]
, the API we have today. Here's the todo app yet again:
type impl struct {
weaver.Implements[Todo]
store weaver.Ref[MemStore]
}
Rather than calling weaver.Get[MemStore]
, the impl
struct embeds
weaver.Ref[MemStore]
. When the Service Weaver runtime constructs an impl
, it
initializes the weaver.Ref[MemStore]
with a reference to the MemStore
component.
With this approach, every Service Weaver application implicitly defines a static
component call graph. This makes it easier to understand which components
communicate with each other. We even have a tool, weaver callgraph
, that
extracts and visualizes this call graph. Moreover, at runtime, we use
mTLS to ensure that components only communicate with the components to
which they have references.
May 30, 2023: Introduce Main Method
We started to think that the signature of weaver.Run
was a bit too
complicated. It was difficult to understand unless you were very familiar with
Go generics:
type PointerToMain[T any] interface {
*T
InstanceOf[Main]
}
func Run[T any, P PointerToMain[T]](ctx context.Context, app func(context.Context, *T) error) error
So, we added a Main
method to the main component:
type Main interface {
Main(context.Context) error
}
And, we simplified weaver.Run
to simply call this Main
method. As a result,
the signature of weaver.Run
was much simpler:
func Run(context.Context)
Here's the todo app again:
func main() {
flag.Parse()
if err := weaver.Run(context.Background()); err != nil {
log.Fatal(err)
}
}
type app struct {
weaver.Implements[weaver.Main]
todo weaver.Ref[Todo]
}
func (a *app) Main(ctx context.Context) {
// ...
}
June 21, 2023: Remove Main Method
Service Weaver ships with a weavertest
package that you can use to test a
Service Weaver application. Here's how to test the Todo
component, for
example:
func TestTodo(t *testing.T) {
weavertest.Local.Test(t, func(t *testing.T, todo Todo) {
// Test Todo here...
})
}
weavertest.Local.Test
is similar to weaver.Run
. It runs a local copy of the
Service Weaver application, initializing all components as necessary. It then
calls the provided function with a handle to the Todo
component.
We felt it was unclear whether the Test
function should run the Main
method
of the main component or not. Because it mirrors weaver.Run
, there's an
argument that it should. However, many tests don't want to or can't run the
Main
method. For example, if a Main
method listens on a hardcoded address
(e.g., localhost:9000
), then two calls to Test
cannot run in parallel if
they both call the Main
method. Ultimately, we decided to revert back to the
more explicit weaver.Run
:
func main() {
flag.Parse()
if err := weaver.Run(context.Background(), serve); err != nil {
log.Fatal(err)
}
}
type app struct {
weaver.Implements[weaver.Main]
todo weaver.Ref[Todo]
}
func serve(ctx context.Context, app *app) {
// ...
}
This version of weaver.Run
clearly runs the provided function (e.g., serve
),
and weavertest.Local.Test
clearly does not.
Lessons Learned
This brings us to the Service Weaver API we have today. The most important lesson here is that good interface design is hard. We went back and forth on how to declare components. One thing that can often help is to model things after existing APIs. E.g., it is easy to define an API for a familiar concept like an associative container. However the problem we faced, embedding remote communication interfaces in the middle of Go, was unfamiliar and therefore we made several missteps. What really helped was taking the time to get experience with what we were building and improving it as we identified rough spots.
Another important realization was the tension between asking users to be precise and reducing boilerplate. E.g., we switched from automatically generating component interfaces to asking users to supply the interfaces. This increased the burden on programmers, but was better for readers - they now get a nicely commented and formatted interface definition to program against and it is clear what to do when making changes to such an interface.
New mini-languages (like Go comment directives) are easy to create, but can get out of hand easily as they are extended in an ad-hoc manner. Use existing languages if possible; if you make a new language, be ultra careful with changes to it.
Declaring things ahead of time can be powerful. We started off with cross-component references instantiated via general Go code, but later switched to pre-declaring such connections. This allows us to fetch the communication graph ahead of time. The graph can be useful for documentation, adding security boundaries, better component placement, etc.