When strings get in the way
You are building a search feature. You have a vector database running Weaviate. You need to find documents similar to a given embedding. You could write the GraphQL query as a raw string, interpolate variables, and send it to the API. That works for a quick prototype. It falls apart when you refactor field names, add filters, or need to construct the query dynamically based on user input. String interpolation hides syntax errors until runtime. You miss a comma and the server rejects the request with a parse error. You change a field name in the schema and forget to update the string. The query returns empty results.
The graphql package from the Weaviate Go client offers a fluent builder pattern. You chain method calls to construct the query programmatically. The builder handles the syntax. You focus on the logic. The compiler catches structural mistakes. You cannot call a method on a string. You call it on the builder. The builder validates the structure as you build it. This approach trades a few lines of boilerplate for safety and maintainability.
The builder pattern in Go
A builder is an object that accumulates state and produces a final result. In Go, the builder pattern typically uses a struct with pointer receivers. Each method modifies the struct's fields and returns the pointer to the struct. This return value allows method chaining. You call WithField, get the builder back, call WithLimit, get the builder back, and so on. The chain reads like a sentence. The builder encapsulates the complexity of constructing the GraphQL query string or JSON payload.
The pattern shines when the query structure is complex or variable. You might need a filter only sometimes. You might need different fields based on the context. With a builder, you add conditional logic to the chain. With a raw string, you concatenate fragments and risk syntax errors. The builder ensures the query remains valid regardless of the path taken.
Minimal example
Here is the simplest way to construct and execute a query using the builder. The code imports the graphql package, creates a context, builds the query, and executes it.
package main
import (
"context"
"fmt"
"github.com/weaviate/weaviate-go-client/v4/weaviate/graphql"
)
func main() {
// Context carries the deadline and cancellation signal.
// Always pass context to long-running operations.
ctx := context.Background()
// Vector represents the embedding for similarity search.
// In a real app, this comes from an embedding model.
vector := []float32{0.1, 0.2, 0.3}
// Start the builder with a GET query.
// The builder returns a pointer to itself for chaining.
result, err := graphql.Get().
// Add a nearVector filter to find similar items.
// The builder stores this filter in its internal state.
WithNearVector(graphql.NearVectorArgBuilder().WithVector(vector)).
// Specify the class to query.
// This maps to the collection in the database.
WithClassName("Document").
// Select the fields to return.
// Only requested fields are sent back in the response.
WithFields(graphql.Field{Name: "text"}).
// Limit the number of results.
// Prevents fetching the entire collection.
WithLimit(3).
// Execute the query against the server.
// This sends the HTTP request and returns the result.
Do(ctx)
// Handle the error from the execution.
// The builder does not fail until Do is called.
if err != nil {
fmt.Printf("query failed: %v\n", err)
return
}
// Result contains the response data.
// The structure depends on the library version and query.
fmt.Printf("result: %+v\n", result)
}
The builder chain starts with graphql.Get(). This function initializes a new builder instance. Each With... method updates the builder's internal state and returns the builder pointer. The final Do(ctx) method sends the constructed query to the server. The result is returned along with an error. If any step fails during execution, the error is captured here. The builder itself does not validate the query against the schema unless the library implements that check. You can still build a query that the server rejects if you use invalid field names. The builder guarantees syntax correctness, not semantic correctness.
How the chain works
Under the hood, the builder struct holds fields for the class name, filters, fields, limit, and other query parameters. When you call WithClassName, the method sets the class name field and returns this. The receiver is a pointer, so the modification persists across calls. The chain is just a series of method calls on the same object. The compiler ensures you call methods in the correct order if the library enforces it. Some builders require you to set the class before adding filters. Others allow any order. The Weaviate client builder is flexible. You can add filters before or after setting the class. The builder assembles the final query string or JSON payload when Do is called.
The context parameter is crucial. Do(ctx) uses the context to manage the request lifecycle. If the context is cancelled, the request is aborted. If the context has a deadline, the request times out. Passing context.Background() works for simple cases, but production code should use a context with a timeout. This prevents the goroutine from hanging indefinitely if the server is slow or unreachable.
Realistic usage with error handling
In a real application, you wrap the builder logic in a function. The function takes parameters and returns the result. You handle errors explicitly. You extract the data from the result. The result structure can be opaque. The library might return a generic map or a specific struct. You need to inspect the result to access the data.
package main
import (
"context"
"fmt"
"github.com/weaviate/weaviate-go-client/v4/weaviate/graphql"
)
// FindSimilarDocuments queries the database for documents similar to the vector.
// It returns the result and any error encountered.
func FindSimilarDocuments(ctx context.Context, vector []float32, limit int) (interface{}, error) {
// Validate inputs before building the query.
// Early return prevents unnecessary work.
if len(vector) == 0 {
return nil, fmt.Errorf("vector cannot be empty")
}
if limit <= 0 {
return nil, fmt.Errorf("limit must be positive")
}
// Build the query using the fluent API.
// Each method call adds a constraint or selection.
result, err := graphql.Get().
WithNearVector(graphql.NearVectorArgBuilder().WithVector(vector)).
WithClassName("Document").
WithFields(graphql.Field{Name: "text"}, graphql.Field{Name: "id"}).
WithLimit(limit).
Do(ctx)
// Check for errors from the server or network.
// The error message contains details about the failure.
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
// Return the result for the caller to process.
// The caller must handle the result structure.
return result, nil
}
func main() {
ctx := context.Background()
vector := []float32{0.1, 0.2, 0.3}
data, err := FindSimilarDocuments(ctx, vector, 5)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
fmt.Printf("data: %+v\n", data)
}
The function FindSimilarDocuments encapsulates the query logic. It validates inputs, builds the query, executes it, and returns the result. The error is wrapped with fmt.Errorf and %w to preserve the error chain. This allows callers to use errors.Is or errors.As to check for specific error types. The result is returned as interface{}. The actual type depends on the library. You might need to type assert the result to access the data. The library documentation will specify the return type. Some versions return a *graphql.Result struct. Others return a map. Check the package docs.
Pitfalls and runtime behavior
The builder pattern is safe, but it has pitfalls. The most common issue is forgetting to call Do. The builder accumulates state but does not send the request until Do is called. If you assign the builder to a variable and forget Do, the query is never executed. The compiler does not catch this. You get a silent failure. The variable holds the builder, not the result. Always chain Do at the end or call it explicitly.
Another pitfall is nil context. If you pass nil to Do, the request might panic or fail immediately. The compiler does not enforce non-nil context. You must check for nil or use context.Background() as a fallback. Production code should always pass a valid context with a timeout. This prevents resource leaks and hanging goroutines.
Result extraction can be tricky. The result structure is often generic. You might get a map[string]interface{}. You need to navigate the map to find the data. This requires type assertions. If the key is missing or the type is wrong, the assertion fails. You get a runtime panic. Use type switches or helper functions to extract data safely. The library might provide helper methods to parse the result. Use them if available.
Compiler errors appear when you misuse the builder. If you forget to import the package, you get undefined: graphql. If you pass the wrong type to a method, you get cannot use ... as ... in argument. For example, passing a string where a slice is expected triggers a type mismatch error. The compiler rejects the program. Fix the types and the error goes away. The builder methods are strongly typed. You cannot pass arbitrary values. This is a benefit over raw strings.
Decision: builder versus alternatives
Use the graphql builder when you need to construct queries dynamically based on runtime data. Use the builder when you want compile-time safety for query structure. Use the builder when the query is complex and hard to maintain as a string. Reach for raw strings when the query is static, simple, and unlikely to change. Raw strings are faster to write and easier to read for trivial cases. Pick code generation when you have a stable schema and want full type safety for both queries and results. Code generators create Go structs and methods from the schema. You get compile-time checks for field names and types. The builder is a middle ground. It offers structure without the overhead of code generation.
Where to go next
- How to Use Partial Templates in Go
- How to Implement Health Check Endpoints in Go
- How to Use templ for Type-Safe Go Templates
The builder constructs. You execute. Context carries the deadline. Trust the chain. Check the error. Extract the data.