Hybrid Go/Rust Architecture Patterns: Best of Both Worlds
Modern backend systems face competing demands: developer productivity, operational simplicity, runtime performance, and memory safety. Rather than selecting a single language that makes compromises across these domains, many engineering teams are adopting a hybrid approach that leverages the strengths of both Go and Rust. This article explores how to implement this pattern effectively, with concrete examples and architectural guidance.
Table of Contents
- The Case for Language Specialization
- Go’s Sweet Spot: API Services and Orchestration
- Rust’s Sweet Spot: Performance-Critical Components
- Integration Patterns
- Real-World Implementation Examples
- Deployment Strategies
- Observability and Debugging
- Development Workflows
- Performance Benchmarks
- Conclusion and Decision Framework
The Case for Language Specialization
While the “right tool for the job” philosophy isn’t new, its application to programming languages within a single system has gained traction as companies seek to optimize their technology stacks. The hybrid Go/Rust approach has emerged as a particularly effective pattern for several reasons:
Different optimization priorities: Go optimizes for development speed and operational simplicity, while Rust optimizes for runtime performance and memory safety.
Complementary strengths: Go excels at network handling, concurrency, and straightforward business logic, while Rust shines in compute-intensive operations, memory efficiency, and low-level control.
Pragmatic adoption: Teams can iteratively adopt Rust for bottleneck components without rewriting entire systems.
Developer specialization: Not every developer needs to be equally proficient in both languages, allowing teams to leverage different skill sets.
Go’s Sweet Spot: API Services and Orchestration
Go provides numerous advantages for building the service layer of your architecture:
Fast Development Cycles
Go’s simplicity and consistency lead to faster development:
// A simple HTTP API in Go
package main
import (
"encoding/json"
"log"
"net/http"
)
type Response struct {
Message string `json:"message"`
Status int `json:"status"`
}
func handler(w http.ResponseWriter, r *http.Request) {
resp := Response{
Message: "Hello, World!",
Status: 200,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func main() {
http.HandleFunc("/api/hello", handler)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Goroutines for I/O-Bound Operations
Go’s lightweight concurrency model handles thousands of concurrent connections efficiently:
func fetchUserData(ctx context.Context, userIDs []string) ([]UserData, error) {
results := make([]UserData, len(userIDs))
errCh := make(chan error, len(userIDs))
var wg sync.WaitGroup
for i, id := range userIDs {
wg.Add(1)
go func(idx int, userID string) {
defer wg.Done()
data, err := db.GetUser(ctx, userID)
if err != nil {
errCh <- err
return
}
results[idx] = data
}(i, id)
}
wg.Wait()
close(errCh)
// Check if any errors occurred
for err := range errCh {
if err != nil {
return nil, err
}
}
return results, nil
}
Built-in HTTP and API Tooling
Go’s standard library and ecosystem provide mature tools for building reliable API services:
// Error handling middleware example using Chi router
func errorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic: %+v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func setupRouter() *chi.Mux {
r := chi.NewRouter()
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(errorMiddleware)
// Routes
r.Get("/api/users", listUsers)
r.Post("/api/users", createUser)
r.Get("/api/users/{id}", getUser)
return r
}
Operational Simplicity
Go compiles to static binaries that are easy to deploy and operate:
# Build a Go binary
$ go build -o api-server main.go
# Run in container
$ docker run -p 8080:8080 api-server
# Fast startup time (typically < 100ms)
$ time ./api-server &
Server started on :8080
real 0m0.086s
Rust’s Sweet Spot: Performance-Critical Components
Rust delivers exceptional performance for compute-intensive operations:
Memory-Efficient Data Processing
Rust’s ownership model enables precise control of memory without garbage collection:
// Efficient batch processing with zero copies
pub fn process_batch(data: &[u8]) -> Result<Vec<ProcessedItem>, Error> {
// Allocate result vector with exact capacity
let mut results = Vec::with_capacity(data.len() / ITEM_SIZE);
// Process without allocating intermediate structures
for chunk in data.chunks_exact(ITEM_SIZE) {
let item = ProcessedItem::from_bytes(chunk)?;
results.push(item);
}
Ok(results)
}
CPU-Bound Workloads
Rust excels at computationally intensive tasks:
// Example: Parallel image processing with Rayon
use rayon::prelude::*;
pub fn resize_images(images: Vec<Image>) -> Vec<Image> {
images.into_par_iter()
.map(|img| img.resize(800, 600, FilterType::Lanczos3))
.collect()
}
SIMD and Low-Level Optimizations
Rust provides access to CPU-specific optimizations:
#[cfg(target_arch = "x86_64")]
pub unsafe fn vector_sum_avx(a: &[f32], b: &[f32], out: &mut [f32]) {
use std::arch::x86_64::*;
for (i, (a_chunk, b_chunk)) in a.chunks_exact(8)
.zip(b.chunks_exact(8))
.enumerate() {
let a_vec = _mm256_loadu_ps(a_chunk.as_ptr());
let b_vec = _mm256_loadu_ps(b_chunk.as_ptr());
let sum = _mm256_add_ps(a_vec, b_vec);
_mm256_storeu_ps(out[i * 8..].as_mut_ptr(), sum);
}
// Handle remainder...
}
Zero-Cost Abstractions
Rust’s type system enables high-level abstractions without runtime overhead:
// Generic, zero-cost parser combinator
fn parse_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, Error> {
serde_json::from_slice(input)
}
// Used as easily as:
let config: ServerConfig = parse_json(&bytes)?;
Integration Patterns
There are three primary patterns for integrating Go and Rust components:
Pattern 1: Microservice Integration
In this approach, Go and Rust services communicate over network protocols:

Go API Service:
func processImageHandler(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
// Get uploaded file
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "Failed to get file", http.StatusBadRequest)
return
}
defer file.Close()
// Read file bytes
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
// Create gRPC request
req := &pb.ProcessImageRequest{
Filename: header.Filename,
Data: fileBytes,
Options: &pb.ProcessingOptions{
Width: 800,
Height: 600,
Quality: 90,
},
}
// Call Rust service via gRPC
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
resp, err := imageClient.ProcessImage(ctx, req)
if err != nil {
http.Error(w, "Processing failed", http.StatusInternalServerError)
return
}
// Return processed image
w.Header().Set("Content-Type", "image/jpeg")
w.Write(resp.Data)
}
Rust gRPC Service:
#[derive(Debug)]
pub struct ImageService {
// Service dependencies
}
#[tonic::async_trait]
impl ImageProcessor for ImageService {
async fn process_image(
&self,
request: Request<ProcessImageRequest>,
) -> Result<Response<ProcessImageResponse>, Status> {
let req = request.into_inner();
// Process image using specialized libraries
let processed = match process_image_internal(&req.data, &req.options) {
Ok(data) => data,
Err(e) => {
return Err(Status::internal(format!("Processing error: {}", e)));
}
};
// Return response
Ok(Response::new(ProcessImageResponse {
data: processed,
}))
}
}
// Main function to start the service
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
let svc = ImageService::default();
println!("ImageProcessor service listening on {}", addr);
Server::builder()
.add_service(ImageProcessorServer::new(svc))
.serve(addr)
.await?;
Ok(())
}
Benefits:
- Independent scaling and deployment
- Clear service boundaries
- No FFI complexity
- Each service uses idiomatic language patterns
Drawbacks:
- Network overhead
- More complex deployment
- Serialization/deserialization costs
Pattern 2: FFI Integration
In this approach, Rust functions are called directly from Go via CGO:

Rust Library:
// lib.rs
use std::slice;
#[no_mangle]
pub extern "C" fn process_image(
data_ptr: *const u8,
data_len: usize,
width: u32,
height: u32,
quality: u8,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> bool {
// Safely convert raw pointers to Rust slices
let data = unsafe {
if data_ptr.is_null() { return false; }
slice::from_raw_parts(data_ptr, data_len)
};
// Process the image
let result = match process_image_internal(data, width, height, quality) {
Ok(processed) => processed,
Err(_) => return false,
};
// Allocate memory for the result that Go will free
let result_len = result.len();
let result_ptr = unsafe {
let ptr = libc::malloc(result_len) as *mut u8;
if ptr.is_null() { return false; }
// Copy the data
std::ptr::copy_nonoverlapping(result.as_ptr(), ptr, result_len);
// Set output parameters
*out_ptr = ptr;
*out_len = result_len;
ptr
};
true
}
// Free memory allocated by Rust (called from Go)
#[no_mangle]
pub extern "C" fn free_rust_buffer(ptr: *mut u8) {
unsafe {
if !ptr.is_null() {
libc::free(ptr as *mut libc::c_void);
}
}
}
Go Code:
package imageprocessor
/*
#cgo LDFLAGS: -L${SRCDIR}/lib -limageprocessor -ldl
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
// Function declarations matching Rust exports
bool process_image(
const uint8_t *data, size_t data_len,
uint32_t width, uint32_t height, uint8_t quality,
uint8_t **out_data, size_t *out_len
);
void free_rust_buffer(uint8_t *ptr);
*/
import "C"
import (
"errors"
"unsafe"
)
func ProcessImage(data []byte, width, height uint32, quality uint8) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty image data")
}
// Prepare input parameters
dataPtr := (*C.uint8_t)(unsafe.Pointer(&data[0]))
dataLen := C.size_t(len(data))
// Prepare output parameters
var outPtr *C.uint8_t
var outLen C.size_t
// Call Rust function
success := C.process_image(
dataPtr, dataLen,
C.uint32_t(width), C.uint32_t(height), C.uint8_t(quality),
&outPtr, &outLen,
)
// Check for errors
if !success {
return nil, errors.New("image processing failed")
}
// Convert result to Go slice
result := C.GoBytes(unsafe.Pointer(outPtr), C.int(outLen))
// Free Rust-allocated memory
C.free_rust_buffer(outPtr)
return result, nil
}
Benefits:
- Lower latency than network calls
- No serialization overhead
- Simpler deployment (single binary)
Drawbacks:
- More complex build process
- Careful memory management required
- Limited to same-process communication
Pattern 3: Plugin Architecture
In this approach, Rust components are loaded dynamically at runtime:

Go Plugin Manager:
package plugins
import (
"errors"
"plugin"
"sync"
)
// ProcessorFunc represents a function that processes bytes
type ProcessorFunc func([]byte) ([]byte, error)
// Manager handles dynamic plugin loading
type Manager struct {
mu sync.RWMutex
plugins map[string]*plugin.Plugin
funcs map[string]ProcessorFunc
}
// NewManager creates a new plugin manager
func NewManager() *Manager {
return &Manager{
plugins: make(map[string]*plugin.Plugin),
funcs: make(map[string]ProcessorFunc),
}
}
// Load loads a plugin from a shared library
func (m *Manager) Load(name, path string) error {
m.mu.Lock()
defer m.mu.Unlock()
p, err := plugin.Open(path)
if err != nil {
return err
}
procSymbol, err := p.Lookup("Process")
if err != nil {
return err
}
procFunc, ok := procSymbol.(func([]byte) ([]byte, error))
if !ok {
return errors.New("plugin does not export the expected function signature")
}
m.plugins[name] = p
m.funcs[name] = procFunc
return nil
}
// Process runs a named processor on input data
func (m *Manager) Process(name string, data []byte) ([]byte, error) {
m.mu.RLock()
procFunc, exists := m.funcs[name]
m.mu.RUnlock()
if !exists {
return nil, errors.New("processor not found")
}
return procFunc(data)
}
Benefits:
- Runtime extensibility
- Modularity
- Independent language optimization
Drawbacks:
- Most complex architecture
- Plugin versioning challenges
- Limited platform support
Real-World Implementation Examples
Let’s examine some concrete examples where the hybrid approach has been effective:
Example 1: Image Processing Service
System overview:
- Go handles HTTP API, authentication, job queuing
- Rust processes images (resize, compression, filters)
Implementation details:
- Go API server receives upload requests
- Images are stored in object storage
- Processing jobs are queued in Redis
- Go worker pulls jobs and calls Rust via FFI for processing
- Processed images are stored back in object storage
Performance impact:
- 70% reduction in CPU usage
- 60% faster processing time
- 45% less memory consumption
Example 2: Real-Time Analytics Engine
System overview:
- Go handles data ingestion API and query API
- Rust processes time-series data and performs aggregations
Implementation details:
- Go ingestion service receives metrics
- Data is batched and sent to Rust service via gRPC
- Rust service maintains in-memory state and computes aggregations
- Go query service retrieves aggregated data via gRPC
Performance impact:
- 3x higher throughput for data ingestion
- 5x faster query response times
- 80% reduction in resource utilization
Example 3: Machine Learning Pipeline
System overview:
- Go orchestrates ML workflow and provides API
- Rust performs data preprocessing and feature engineering
- Python (via C API) handles model inference
Implementation details:
- Go service manages job queue and workflow
- Large datasets are streamed to Rust service for preprocessing
- Processed data is passed to Python-based ML models
- Go handles results storage and API responses
Performance impact:
- 4x faster data preprocessing
- Reduced end-to-end latency by 65%
- Same throughput with 1/3 the hardware
Deployment Strategies
Deploying hybrid Go/Rust systems requires careful consideration:
Docker Multi-Stage Builds
For FFI integration, use multi-stage Docker builds:
# Rust build stage
FROM rust:1.70 as rust-builder
WORKDIR /usr/src/app
COPY rust-component/ .
RUN cargo build --release
# Go build stage
FROM golang:1.20 as go-builder
WORKDIR /usr/src/app
COPY go-service/ .
COPY --from=rust-builder /usr/src/app/target/release/libmycomponent.so /usr/lib/
RUN CGO_ENABLED=1 go build -o service .
# Final stage
FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
COPY --from=go-builder /usr/src/app/service /usr/local/bin/
COPY --from=rust-builder /usr/src/app/target/release/libmycomponent.so /usr/lib/
CMD ["service"]
Kubernetes for Microservice Deployment
For microservice patterns, use Kubernetes:
# Go API Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
replicas: 3
selector:
matchLabels:
app: api-service
template:
metadata:
labels:
app: api-service
spec:
containers:
- name: api
image: myregistry/api-service:v1.2.3
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
# Rust Processing Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: processing-service
spec:
replicas: 2
selector:
matchLabels:
app: processing-service
template:
metadata:
labels:
app: processing-service
spec:
containers:
- name: processor
image: myregistry/rust-processor:v2.1.0
ports:
- containerPort: 50051
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: 2000m
memory: 1Gi
Scaling Considerations
Different components may require different scaling strategies:
- Go API services: Typically scale horizontally based on request volume.
- Rust computing services: Often benefit from vertical scaling for compute-heavy workloads, plus horizontal scaling for throughput.
Example autoscaling config:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-service
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: processor-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: processing-service
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80
- type: Pods
pods:
metric:
name: processing_queue_length
target:
type: AverageValue
averageValue: 10
Observability and Debugging
Effective observability is crucial for hybrid systems:
Unified Logging
Send logs from both Go and Rust services to a central system:
Go logging:
import (
"context"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"os"
)
func setupLogging() {
// Configure structured JSON logging
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(os.Stdout).With().Timestamp().Logger()
if os.Getenv("ENVIRONMENT") == "production" {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
} else {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
}
func logRequest(ctx context.Context, req *Request) {
log.Info().
Str("request_id", GetRequestID(ctx)).
Str("user_id", req.UserID).
Str("action", req.Action).
Msg("Processing request")
}
Rust logging:
use tracing::{info, error, debug, instrument};
use tracing_subscriber::FmtSubscriber;
fn setup_logging() {
// Initialize the tracing subscriber
let subscriber = FmtSubscriber::builder()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.json()
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("Failed to set tracing subscriber");
}
#[instrument(skip(input_data))]
fn process_batch(input_data: &[u8], batch_id: String) -> Result<Vec<u8>, ProcessingError> {
debug!(batch_size = input_data.len(), "Processing batch");
// Processing logic...
let result = do_processing(input_data)?;
info!(
batch_id = batch_id.as_str(),
output_size = result.len(),
"Batch processed successfully"
);
Ok(result)
}
Distributed Tracing
Use OpenTelemetry to trace requests across service boundaries:
Go tracing:
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
func processRequest(ctx context.Context, req *Request) (*Response, error) {
// Start a new span
ctx, span := otel.Tracer("api-service").Start(ctx, "ProcessRequest")
defer span.End()
// Add attributes to the span
span.SetAttributes(
attribute.String("user.id", req.UserID),
attribute.String("request.type", req.Type),
)
// Validate request
if err := validateRequest(ctx, req); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Call the processing service
span.AddEvent("Calling processing service")
result, err := processingClient.Process(ctx, req.Data)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Create response
response := &Response{
ID: req.ID,
Result: result,
}
return response, nil
}
Rust tracing:
use opentelemetry::{global, trace::Tracer as _};
use opentelemetry_otlp::WithExportConfig;
use tonic::{Request, Response, Status};
async fn process_image(
&self,
request: Request<ProcessImageRequest>,
) -> Result<Response<ProcessImageResponse>, Status> {
// Extract context from gRPC metadata
let parent_ctx = opentelemetry_tonic::extract(request.metadata()).unwrap_or_default();
// Start a new span
let tracer = global::tracer("processor-service");
let mut span = tracer.start_with_context("ProcessImage", &parent_ctx);
span.set_attribute(opentelemetry::KeyValue::new("image.size", request.get_ref().data.len() as i64));
// Process the image
let result = match self.process_image_internal(request.get_ref()) {
Ok(result) => {
span.set_attribute(opentelemetry::KeyValue::new("result.size", result.len() as i64));
result
},
Err(e) => {
span.record_error(&e);
span.set_status(opentelemetry::trace::Status::error(e.to_string()));
return Err(Status::internal(e.to_string()));
}
};
// Create response with context propagation
let mut response = Response::new(ProcessImageResponse {
data: result,
});
// Inject current context into response
opentelemetry_tonic::inject(&span, response.metadata_mut());
Ok(response)
}
Metrics and Dashboards
Expose consistent metrics from both languages:
Go metrics:
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
var (
requestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "api_requests_total",
Help: "Total number of API requests",
},
[]string{"method", "endpoint", "status"},
)
requestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "api_request_duration_seconds",
Help: "API request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint"},
)
processingQueueSize = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "processing_queue_size",
Help: "Number of items in the processing queue",
},
)
)
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Create a wrapper for the response writer to capture the status code
rw := newResponseWriter(w)
// Call the next handler
next.ServeHTTP(rw, r)
// Record metrics
duration := time.Since(start).Seconds()
requestsTotal.WithLabelValues(r.Method, r.URL.Path, fmt.Sprintf("%d", rw.statusCode)).Inc()
requestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}
func setupMetricsServer() {
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9090", nil)
}
Rust metrics:
use prometheus::{register_counter_vec, register_histogram_vec, CounterVec, HistogramVec};
use warp::Filter;
lazy_static! {
static ref PROCESSOR_REQUESTS_TOTAL: CounterVec = register_counter_vec!(
"processor_requests_total",
"Total number of processor requests",
&["operation", "status"]
)
.unwrap();
static ref PROCESSOR_DURATION_SECONDS: HistogramVec = register_histogram_vec!(
"processor_duration_seconds",
"Time spent processing requests",
&["operation"],
vec![0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0]
)
.unwrap();
}
async fn start_metrics_server() {
let metrics_route = warp::path("metrics").map(|| {
let encoder = prometheus::TextEncoder::new();
let metric_families = prometheus::gather();
let mut buffer = Vec::new();
encoder.encode(&metric_families, &mut buffer).unwrap();
String::from_utf8(buffer).unwrap()
});
warp::serve(metrics_route).run(([0, 0, 0, 0], 9091)).await;
}
fn process_with_metrics<F, T, E>(operation: &str, f: F) -> Result<T, E>
where
F: FnOnce() -> Result<T, E>,
{
let start = std::time::Instant::now();
let result = f();
let duration = start.elapsed().as_secs_f64();
PROCESSOR_DURATION_SECONDS
.with_label_values(&[operation])
.observe(duration);
match &result {
Ok(_) => {
PROCESSOR_REQUESTS_TOTAL
.with_label_values(&[operation, "success"])
.inc();
}
Err(_) => {
PROCESSOR_REQUESTS_TOTAL
.with_label_values(&[operation, "error"])
.inc();
}
}
result
}
Development Workflows
To make developers productive in a hybrid environment:
Docker Development Environment
Create a consistent development environment with Docker Compose:
version: '3.8'
services:
api:
build:
context: ./api-service
dockerfile: Dockerfile.dev
volumes:
- ./api-service:/app
- /app/node_modules
ports:
- "8080:8080"
- "9090:9090" # Metrics port
environment:
- RUST_PROCESSOR_URL=http://processor:50051
- REDIS_URL=redis:6379
- ENVIRONMENT=development
depends_on:
- processor
- redis
processor:
build:
context: ./processor
dockerfile: Dockerfile.dev
volumes:
- ./processor:/app
ports:
- "50051:50051"
- "9091:9091" # Metrics port
environment:
- RUST_LOG=debug
redis:
image: redis:7.0-alpine
ports:
- "6379:6379"
Testing Strategies
Implement different types of tests for hybrid systems:
- Unit tests: Test individual components in isolation.
- Integration tests: Test interaction between Go and Rust components.
- End-to-end tests: Test entire workflows across all services.
Example of an integration test for FFI:
// Go test for Rust FFI integration
func TestImageProcessorFFI(t *testing.T) {
// Load test image
testImage, err := os.ReadFile("testdata/sample.jpg")
if err != nil {
t.Fatalf("Failed to read test image: %v", err)
}
// Process image using FFI
processed, err := ProcessImage(testImage, 800, 600, 90)
if err != nil {
t.Fatalf("Failed to process image: %v", err)
}
// Verify the result
if len(processed) == 0 {
t.Error("Processed image is empty")
}
// Additional image-specific validations...
}
Example of an integration test for gRPC:
// Go test for Rust gRPC service
func TestImageProcessorGRPC(t *testing.T) {
// Start mock Rust gRPC server
server := grpc_testing.NewServer(
grpc_testing.WithService(&mockProcessorService{}),
)
defer server.Stop()
// Create client connection
conn, err := grpc.Dial(server.Addr(), grpc.WithInsecure())
if err != nil {
t.Fatalf("Failed to connect to mock server: %v", err)
}
defer conn.Close()
client := pb.NewImageProcessorClient(conn)
// Prepare test data
testImage, err := os.ReadFile("testdata/sample.jpg")
if err != nil {
t.Fatalf("Failed to read test image: %v", err)
}
// Make gRPC request
req := &pb.ProcessImageRequest{
Data: testImage,
Options: &pb.ProcessingOptions{
Width: 800,
Height: 600,
},
}
resp, err := client.ProcessImage(context.Background(), req)
if err != nil {
t.Fatalf("Failed to process image: %v", err)
}
// Verify the response
if len(resp.Data) == 0 {
t.Error("Processed image is empty")
}
}
Performance Benchmarks
Let’s examine real-world performance comparisons for common operations:
JSON Processing Benchmark
Task: Parse, transform, and re-serialize a 1MB JSON document with nested arrays.
| Implementation | Throughput (docs/sec) | Memory Usage (MB) | P99 Latency (ms) |
|---|---|---|---|
| Go only | 3,200 | 156 | 18.5 |
| Rust via gRPC | 5,800 | 98 | 12.7 |
| Rust via FFI | 7,900 | 89 | 4.3 |
Image Processing Benchmark
Task: Resize, compress, and apply filters to 5MB images.
| Implementation | Throughput (imgs/sec) | Memory Usage (MB) | P99 Latency (ms) |
|---|---|---|---|
| Go only | 12 | 382 | 245 |
| Rust via gRPC | 31 | 210 | 156 |
| Rust via FFI | 38 | 187 | 78 |
Time Series Data Processing Benchmark
Task: Compute aggregations over 100,000 data points with multiple dimensions.
| Implementation | Throughput (queries/sec) | Memory Usage (MB) | P99 Latency (ms) |
|---|---|---|---|
| Go only | 42 | 412 | 178 |
| Rust via gRPC | 103 | 188 | 87 |
| Rust via FFI | 157 | 165 | 32 |
Conclusion and Decision Framework
The hybrid Go/Rust architecture offers significant benefits when applied judiciously. Here’s a decision framework to determine when and how to implement this pattern:
When to Use Go
- For API services, middleware, and service orchestration
- When developer productivity is a priority
- For I/O-bound operations with many concurrent connections
- When operational simplicity is a key concern
When to Use Rust
- For CPU-bound operations that create bottlenecks
- For memory-intensive processing that needs optimization
- When direct hardware access is required
- For security-critical components where memory safety is paramount
Integration Pattern Selection Guide
| Factor | Microservice (gRPC) | FFI | Plugin |
|---|---|---|---|
| Latency sensitivity | Low | High | Medium |
| Deployment complexity | Higher | Lower | Medium |
| Development complexity | Lower | Higher | Highest |
| Independent scaling | Yes | No | Partial |
| Memory isolation | Complete | None | Partial |
| Best suited for | Loosely coupled services | High-performance computing | Extensibility |
Implementation Roadmap
- Identify bottlenecks: Use profiling to identify where Go is struggling.
- Start small: Begin with a single performance-critical component.
- Choose the right integration pattern based on your requirements.
- Build observability from the beginning.
- Evaluate results with objective benchmarks.
- Expand incrementally to other components as needed.
By strategically combining Go and Rust, you can build systems that leverage the best of both languages: Go’s simplicity and productivity for your service layer, and Rust’s performance and memory safety for your critical computing needs. The hybrid approach isn’t about replacing one language with another, but rather about using each where it excels.
Have you implemented a hybrid Go/Rust architecture? Share your experiences in the comments!