Service Architecture
This guide explains the key architectural components of async-service
and how they work together to provide a clean separation of concerns.
Core Components
Async::Service
is built around four main concepts:
- Environment: Represents a service configuration and provides lazy evaluation of settings.
- Service: Represents a specific service implementation, defined by an environment.
- Configuration: Represents one or more configured services, defined by environments.
- Container: Used to run the actual service logic in child processes or threads.
Environment
The class Async::Service::Environment
represents a lazy-evaluated chain of key-value pairs. It handles configuration storage, lazy evaluation of computed values, and composition through module inclusion to create flexible, reusable service configurations.
Configuration Storage
Each service definition creates an environment:
service "web" do
port 3000
host "localhost"
end
Lazy Evaluation
Environments use lazy evaluation through evaluators:
service "web" do
root "/app"
# Computed when accessed:
log_path {File.join(root, "logs", "app.log")}
end
The log_path
is calculated when the service accesses it, not when defined.
Composition and Inheritance
Environments support modular configuration:
module DatabaseEnvironment
def database_url
"postgresql://localhost/app"
end
end
service "web" do
# Environment includes the module:
include DatabaseEnvironment
database {Database.new(database_url)}
end
You can also explicitly create environments:
SharedEnvironment = environment do
port 3000
host "localhost"
url {"http://#{host}:#{port}"}
end
service "web1" do
include SharedEnvironment
port 3001
end
service "web2" do
include SharedEnvironment
port 3002
end
Service
The class Async::Service::Generic
represents the service implementation layer. It handles the actual business logic of your services, provides access to configuration through environment evaluators, and manages the service lifecycle including startup, execution, and shutdown phases.
Business Logic
Services contain the actual implementation of what your service does:
class WebService < Async::Service::Generic
def setup(container)
super
# Define how the service runs:
container.run(count: 1, restart: true) do |instance|
# Access configuration through evaluator:
evaluator = self.environment.evaluator
# Indicate that the service instance is healthy thus far:
instance.ready!
# Your service implementation:
start_web_server(evaluator.host, evaluator.port)
end
end
end
Configuration Access
Services access their configuration through their environment's evaluator:
def setup(container)
super
container.run do |instance|
# Create a new evaluator as we are in a different execution context:
evaluator = self.environment.evaluator
# Use evaluator in service configuration:
database_url = evaluator.database_url
max_connections = evaluator.max_connections
# Your service implementation.
end
end
Within the service class itself, you can use @evaluator
. However, evaluators are not thread safe, so you should not use them across threads, and instead create distinct evaluators for each child process, thread or ractor, etc.
Lazy Evaluation
Environment evaluators are lazy-evaluated and memoized, meaning that any configuration defined within them is not computed until it is accessed. By doing this in the scope of the child process, you can ensure that each instance has its own configuration values.
service do
log_path {File.join(root, "logs", "app-#{Process.pid}.log")}
end
By evaluating the log_path in the child process, you ensure that each instance has its own log file with the correct process ID.
Lifecycle Management
Services define their startup, running, and shutdown behavior:
class MyService < Async::Service::Generic
def start
super
# Service-specific startup logic including pre-loading libraries and binding to network interfaces before forking.
end
def setup(container)
super
# Define container execution:
container.run do |instance|
# Your service implementation.
end
end
def stop(graceful = true)
# Service-specific cleanup including releasing any resources acquired during startup.
super
end
end
Configuration
The class Async::Service::Configuration
represents the top-level orchestration layer. It handles service definition and registration, provides service discovery and management capabilities, supports loading configurations from files, and enables introspection of defined services and their settings.
Service Definitions
configuration = Async::Service::Configuration.build do
service "web" do
service_class WebService
port 3000
host "localhost"
end
service "worker" do
service_class WorkerService
worker_count 4
end
end
Service Discovery and Management
# Access all services in the configuration:
configuration.services.each do |service|
puts "Service: #{service.name}"
end
# Create a controller to manage all services:
controller = configuration.controller
# Start all services:
controller.start
Loading from Files
# Load services from configuration files:
configuration = Async::Service::Configuration.load(["web.rb", "worker.rb"])
# Or load from command line arguments:
configuration = Async::Service::Configuration.load(ARGV)
Introspection
Given a service.rb
file, you can list the defined services and their configurations.
$ bundle exec bake async:service:configuration:load service.rb async:service:configuration:list o
utput --format json
[
{
"port": 3000,
"name": "web",
"host": "localhost",
"service_class": "WebService",
"root": "/Users/samuel/Developer/socketry/async-service"
},
{
"name": "worker",
"worker_count": 4,
"service_class": "WorkerService",
"root": "/Users/samuel/Developer/socketry/async-service"
}
]
Note that only service do ... end
definitions are included in the configuration listing.
Container
The container layer (provided by the async-container
gem) handles process management. Services don't interact with containers directly, but configure how containers should run them.
Process Management
def setup(container)
# Configure how many instances, restart behavior, etc.
container.run(count: 4, restart: true) do |instance|
# This block runs in a separate process/fiber
instance.ready!
# Your service implementation.
end
end
Health Checking
def setup(container)
container_options = @evaluator.container_options
health_check_timeout = container_options[:health_check_timeout]
container.run(**container_options) do |instance|
# Prepare your service.
Sync do
# Start your service.
# Set up health checking, if a timeout was specified:
health_checker(instance, health_check_timeout) do
instance.name = "#{self.name}: #{current_status}"
end
end
end
end
How They Work Together
The four layers interact in a specific pattern:
1. Environment Creation
# Environments define individual service configuration:
module DatabaseEnvironment
def database_url
"postgresql://localhost/app"
end
end
2. Service Definition
# Services are defined using environments:
class WebService < Async::Service::Generic
def setup(container)
super
# Access configuration through evaluator
evaluator = @environment.evaluator
port = evaluator.port
# Define how the service runs
container.run(count: 1, restart: true) do |instance|
instance.ready!
# Your service implementation:
start_web_server(port)
end
end
end
3. Configuration Assembly
#!/usr/bin/env async-service
require "database_environment"
require "web_service"
service "web" do
include DatabaseEnvironment # Use the environment
service_class WebService
port 3000
end
4. Container Execution
# Load the service configurations:
configuration = Async::Service::Configuration.load("service.rb")
# Controller manages the actual execution using containers:
Async::Service::Controller.run(configuration)
Benefits of This Architecture
Separation of Concerns
- Environment: "How should individual service configuration be stored and evaluated?"
- Service: "What should each service do when it runs?"
- Configuration: "What services should run and how should they be combined?"
- Container: "How should services be executed and monitored in processes?"
Testability
# Test configuration
configuration = Async::Service::Configuration.build do
service "test-service" do
service_class MyService
port 3001
database_url "sqlite://memory"
end
end
# Test individual service
service = configuration.services.first
mock_container = double("container")
service.setup(mock_container)
Flexibility
- Define multiple services in one configuration
- Same service class with different configurations
- Load configurations from files or build programmatically
- Different container strategies per service
Reusability
# Reusable environment modules:
module DatabaseEnvironment
def database_url
"postgresql://localhost/app"
end
end
module RedisEnvironment
def redis_url
"redis://localhost:6379"
end
end
# Compose in service definitions:
configuration = Async::Service::Configuration.build do
service "worker" do
include DatabaseEnvironment
include RedisEnvironment
service_class WorkerService
end
end
Common Patterns
Configuration Files
Create reusable configuration files:
# config/web.rb
service "web" do
service_class WebService
port 3000
host "0.0.0.0"
end
# config/worker.rb
service "worker" do
service_class WorkerService
concurrency 4
end
# Load both:
configuration = Async::Service::Configuration.load(["config/web.rb", "config/worker.rb"])
Environment Modules
Create reusable configuration modules:
module ContainerEnvironment
def count
4
end
def restart
true
end
def health_check_timeout
30
end
end
configuration = Async::Service::Configuration.build do
service "my-service" do
include ContainerEnvironment
service_class MyService
end
end
Service Inheritance
Build service hierarchies:
class BaseWebService < Async::Service::Generic
def setup(container)
super
# Common web service setup
end
end
class APIService < BaseWebService
def setup(container)
super
# API-specific setup
end
end
Programmatic Configuration
Build configurations dynamically:
configuration = Async::Service::Configuration.new
# Add services programmatically
["web", "api", "worker"].each do |name|
environment = Async::Service::Environment.build do
service_class MyService
service_name name
port 3000 + name.hash % 1000
end
configuration.add(environment)
end
This architecture provides a clean, testable, and flexible foundation for building services while maintaining clear boundaries between configuration, implementation, and execution concerns.