Skip to content

Architecture

RPC Dart follows a layered architecture that separates concerns and provides flexibility in how components communicate. Understanding this architecture will help you build better applications and make informed decisions about transport selection and service design.

RPC Dart’s architecture consists of several layers, each with specific responsibilities:

Application Layer

Your business logic and service implementations that use RPC contracts.

RPC Layer

Contracts, callers, responders, and routing logic.

Transport Layer

Network communication, serialization, and message delivery.

Core Layer

Base classes, utilities, and framework fundamentals.

┌─────────────────────────────────────────┐
│ Application Layer │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Service │ │ Client │ │
│ │ A │ │Application │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ RPC Layer │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Responder │ │ Caller │ │
│ │ Endpoint │ │ Endpoint │ │
│ └─────────────┘ └─────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ Contracts │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Transport Layer │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │InMemory │ │ HTTP │ │WebSocket│ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Core Layer │
│ ┌─────────────────────────────────┐ │
│ │ Base Classes & Utilities │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘

Contracts define the interface between services. They specify:

  • Service names and method names
  • Request and response types
  • Supported RPC patterns (unary, streaming)
abstract interface class IUserServiceContract {
static const name = 'UserService';
static const methodGetUser = 'getUser';
static const methodUpdateUser = 'updateUser';
static const methodStreamNotifications = 'streamNotifications';
}

Handles incoming RPC requests:

final responder = RpcResponderEndpoint(transport: serverTransport);
// Register service implementations
responder.registerServiceContract(UserServiceResponder());
responder.registerServiceContract(PaymentServiceResponder());
// Start handling requests
responder.start();

Initiates RPC calls to remote services:

final caller = RpcCallerEndpoint(transport: clientTransport);
// Create service clients
final userService = UserServiceCaller(caller);
final paymentService = PaymentServiceCaller(caller);
// Make RPC calls
final user = await userService.getUser(userId);

The transport layer handles message delivery between endpoints:

abstract class IRpcTransport {
bool get isClient;
bool get supportsZeroCopy;
int createStream();
Future<void> sendMetadata(int streamId, RpcMetadata metadata, {bool endStream = false});
Future<void> sendMessage(int streamId, Uint8List data, {bool endStream = false});
Future<void> sendDirectObject(int streamId, Object object, {bool endStream = false});
Stream<RpcTransportMessage> get incomingMessages;
Future<void> finishSending(int streamId);
Future<void> close();
Future<RpcHealthStatus> health();
Future<RpcHealthStatus> reconnect();
}

This matches the production interface exposed by RPC Dart and lets you swap transports without rewriting callers or responders.

Services are defined using contracts that specify the interface:

// Contract definition
abstract interface class ICalculatorContract {
static const name = 'Calculator';
static const methodAdd = 'add';
static const methodSubtract = 'subtract';
}
// Responder implementation
class CalculatorResponder extends RpcResponderContract {
CalculatorResponder() : super(ICalculatorContract.name);
@override
void setup() {
addUnaryMethod<AddRequest, AddResponse>(
methodName: ICalculatorContract.methodAdd,
handler: _add,
);
}
Future<AddResponse> _add(AddRequest request, {RpcContext? context}) async {
return AddResponse(request.a + request.b);
}
}
// Caller implementation
class CalculatorCaller extends RpcCallerContract {
CalculatorCaller(RpcCallerEndpoint endpoint)
: super(ICalculatorContract.name, endpoint);
Future<AddResponse> add(AddRequest request) {
return callUnary<AddRequest, AddResponse>(
methodName: ICalculatorContract.methodAdd,
request: request,
);
}
}

For complex applications, you can route different services through different transports:

final router = RpcTransportRouterBuilder.client()
.routeCall(calledServiceName: 'UserService', toTransport: httpTransport)
.routeCall(
calledServiceName: 'NotificationService',
toTransport: webSocketTransport,
)
.routeCall(
calledServiceName: 'CacheService',
toTransport: inMemoryTransport,
)
.build();
final caller = RpcCallerEndpoint(transport: router);

Implement cross-cutting concerns using middleware:

class LoggingMiddleware implements IRpcMiddleware {
@override
Future<dynamic> processRequest(
String serviceName,
String methodName,
dynamic request,
) async {
log('Calling $serviceName.$methodName');
return request;
}
@override
Future<dynamic> processResponse(
String serviceName,
String methodName,
dynamic response,
) async {
log('Completed $serviceName.$methodName');
return response;
}
}
// Register middleware (extend the endpoints to invoke hooks around handlers)
responder.addMiddleware(LoggingMiddleware());
Client Transport Server
│ │ │
│ 1. callUnary() │ │
├─────────────────────────▶│ │
│ │ 2. RpcMessage │
│ ├─────────────────────────▶│
│ │ │ 3. processRequest()
│ │ ├──────────────┐
│ │ │ │
│ │ │◀─────────────┘
│ │ 4. RpcMessage │
│ │◀─────────────────────────┤
│ 5. Response │ │
│◀─────────────────────────┤ │
Client Transport Server
│ │ │
│ 1. callServerStream() │ │
├─────────────────────────▶│ │
│ │ 2. RpcMessage │
│ ├─────────────────────────▶│
│ │ │ 3. processStreamRequest()
│ │ ├──────────────┐
│ │ 4. Stream messages │ │
│ │◀─────────────────────────┤ │
│ 5. Stream<Response> │ │ │
│◀─────────────────────────┤ │ │
│ │ │◀─────────────┘

RPC Dart implements a structured error handling system:

abstract class RpcException implements Exception {
final String code;
final String message;
final Map<String, dynamic>? details;
}
class RpcTimeoutException extends RpcException {
RpcTimeoutException(String message, Duration timeout);
}
class RpcCancelledException extends RpcException {
RpcCancelledException(String message);
}
class RpcTransportException extends RpcException {
RpcTransportException(String message, Exception cause);
}

Errors are automatically serialized and propagated across transport boundaries:

// Server side
Future<UserResponse> getUser(UserRequest request) async {
if (request.userId.isEmpty) {
throw RpcException(
code: 'INVALID_USER_ID',
message: 'User ID cannot be empty',
details: {'field': 'userId'},
);
}
// ... implementation
}
// Client side
try {
final user = await userService.getUser(UserRequest(userId: ''));
} on RpcException catch (e) {
// Handle structured RPC errors
print('Error: ${e.code} - ${e.message}');
if (e.details != null) {
print('Field error: ${e.details!['field']}');
}
}

For single-process applications, InMemory transport provides maximum performance:

class LargeDataService extends RpcResponderContract {
@override
void setup() {
addUnaryMethod<LargeData, ProcessedData>(
methodName: 'processLargeData',
handler: _processLargeData,
);
}
Future<ProcessedData> _processLargeData(LargeData data) async {
// Direct object access - no serialization overhead
return ProcessedData(data.processDirectly());
}
}

Network transports automatically handle serialization:

// Provide codecs when you register methods for network transports
addUnaryMethod<MyRequest, MyResponse>(
methodName: 'Process',
handler: _process,
requestCodec: RpcCodec(MyRequest.fromJson),
responseCodec: RpcCodec(MyResponse.fromJson),
);

RpcHttp2CallerTransport keeps a single multiplexed HTTP/2 connection alive and exposes health/reconnect hooks:

final httpTransport = await RpcHttp2CallerTransport.secureConnect(
host: 'api.example.com',
);
final status = await httpTransport.health();
if (!status.isHealthy) {
await httpTransport.reconnect();
}

Break large applications into focused services:

// User management service
abstract interface class IUserServiceContract {
static const name = 'UserService';
// User CRUD operations
}
// Payment processing service
abstract interface class IPaymentServiceContract {
static const name = 'PaymentService';
// Payment operations
}
// Notification service
abstract interface class INotificationServiceContract {
static const name = 'NotificationService';
// Real-time notifications
}

Use appropriate transports for different service types (for example httpTransport from RpcHttp2CallerTransport.secureConnect and webSocketTransport from RpcWebSocketCallerTransport.connect):

// Real-time services use WebSocket
final notificationService = NotificationServiceCaller(
RpcCallerEndpoint(transport: webSocketTransport)
);
// CRUD services use HTTP
final userService = UserServiceCaller(
RpcCallerEndpoint(transport: httpTransport)
);
// Internal cache uses InMemory
final cacheService = CacheServiceCaller(
RpcCallerEndpoint(transport: inMemoryTransport)
);

Use multiple transports together with RpcTransportRouter or build a custom transport by extending RpcBaseTransport. You can rotate between several RpcHttp2CallerTransport instances or route based on metadata such as tenant ID and latency.

RPC Dart’s architecture makes testing straightforward:

class MockUserService extends RpcResponderContract {
MockUserService() : super(IUserServiceContract.name);
@override
void setup() {
addUnaryMethod<GetUserRequest, UserResponse>(
methodName: IUserServiceContract.methodGetUser,
handler: _getMockUser,
);
}
Future<UserResponse> _getMockUser(GetUserRequest request) async {
return UserResponse(
user: User(id: request.userId, name: 'Test User'),
);
}
}
void main() {
group('UserService Integration', () {
late RpcResponderEndpoint responder;
late RpcCallerEndpoint caller;
setUp(() async {
final (clientTransport, serverTransport) = RpcInMemoryTransport.pair();
responder = RpcResponderEndpoint(transport: serverTransport);
responder.registerServiceContract(MockUserService());
responder.start();
caller = RpcCallerEndpoint(transport: clientTransport);
});
tearDown(() async {
await caller.close();
await responder.close();
});
test('should get user successfully', () async {
final userService = UserServiceCaller(caller);
final response = await userService.getUser(GetUserRequest(userId: '123'));
expect(response.user.id, equals('123'));
expect(response.user.name, equals('Test User'));
});
});
}
  • Single Responsibility: Each service should have one clear purpose
  • Interface Segregation: Keep contracts focused and minimal
  • Dependency Inversion: Depend on contracts, not implementations
  • Structured Errors: Use RpcException with codes and details
  • Graceful Degradation: Handle errors at appropriate levels
  • Circuit Breaker: Implement timeouts and retries
  • Transport Selection: Choose the right transport for each use case
  • Streaming: Use streaming for large datasets and real-time updates
  • Caching: Implement caching where appropriate
  • Unit Tests: Test contracts and implementations separately
  • Integration Tests: Test with real transports
  • Mock Services: Use InMemory transport for fast, isolated tests

The architecture of RPC Dart provides flexibility, performance, and maintainability while keeping the complexity manageable. By understanding these patterns and principles, you can build robust, scalable applications that are easy to test and maintain.