Skip to content

Core Concepts

RPC Dart is built around several core concepts that work together to provide a powerful and flexible Remote Procedure Call framework. Understanding these concepts is essential to effectively using the library.

Contracts

Define the interface between services with method names and signatures.

Endpoints

Handle communication between client and server sides.

Transports

Manage the underlying communication mechanism (InMemory, HTTP, WebSocket, etc.).

Context

Carry metadata and control information across RPC calls.

Contracts in RPC Dart define the service interface - what methods are available and how they should be called. They serve as the single source of truth for both client and server implementations.

abstract interface class ICalculatorContract {
static const name = 'Calculator';
static const methodAdd = 'add';
static const methodSubtract = 'subtract';
static const methodDivide = 'divide';
}
  • Type Safety: Ensures consistent method signatures across client and server
  • Documentation: Serves as living documentation of available services
  • Versioning: Easy to version and evolve your API
  • Code Generation: Can be used to generate client and server stubs

Endpoints are the communication points that handle RPC calls. There are two types:

The client-side endpoint that initiates RPC calls:

final caller = RpcCallerEndpoint(transport: transport);
final calculator = CalculatorCaller(caller);
// Make RPC call
final result = await calculator.add(AddRequest(5, 3));

The server-side endpoint that handles incoming RPC calls:

final responder = RpcResponderEndpoint(transport: transport);
responder.registerServiceContract(CalculatorResponder());
responder.start();

Transports define how messages are sent between endpoints. RPC Dart is transport-agnostic, meaning you can switch transports without changing your business logic.

TransportUse CasePerformanceComplexity
InMemoryTesting, single-processHighest (zero-copy)Lowest
HTTPWeb services, microservicesMediumMedium
WebSocketReal-time, bidirectionalMediumMedium
IsolateMulti-core, isolationHighHigher
// Development/Testing
final (clientTransport, serverTransport) = RpcInMemoryTransport.pair();
// Production HTTP
final httpTransport = RpcHttpTransport(
url: 'https://api.example.com',
timeout: Duration(seconds: 30),
);
// Real-time WebSocket
final wsTransport = RpcWebSocketTransport(
url: 'wss://api.example.com/rpc',
);

The same service code works with any transport!

RPC Context carries additional information alongside the actual RPC call data:

  • Correlation ID: Unique identifier for request tracing
  • Deadline: Request timeout information
  • Metadata: Custom key-value pairs
  • Cancellation: Ability to cancel ongoing operations
// Server side - accessing context
Future<AddResponse> _add(AddRequest request, {RpcContext? context}) async {
final correlationId = context?.correlationId;
final userAgent = context?.metadata['user-agent'];
if (context?.isCancelled ?? false) {
throw RpcException('Request was cancelled');
}
return AddResponse(request.a + request.b);
}
// Client side - passing metadata
final context = RpcContext()
..metadata['user-agent'] = 'MyApp/1.0'
..deadline = DateTime.now().add(Duration(seconds: 5));
final result = await calculator.add(
AddRequest(5, 3),
context: context,
);

RPC Dart supports all standard RPC communication patterns:

Simple request-response pattern:

Future<AddResponse> add(AddRequest request) {
return callUnary<AddRequest, AddResponse>(
methodName: 'add',
request: request,
);
}

Server sends multiple responses to a single request:

Stream<NumberResponse> getNumbers(NumberRequest request) {
return callServerStream<NumberRequest, NumberResponse>(
methodName: 'getNumbers',
request: request,
);
}

Client sends multiple requests, server responds once:

Future<SumResponse> sumNumbers(Stream<NumberRequest> requests) {
return callClientStream<NumberRequest, SumResponse>(
methodName: 'sumNumbers',
requests: requests,
);
}

Both client and server can send multiple messages:

Stream<EchoResponse> echo(Stream<EchoRequest> requests) {
return callBidirectionalStream<EchoRequest, EchoResponse>(
methodName: 'echo',
requests: requests,
);
}

One of RPC Dart’s unique features is zero-copy object transfer with InMemory transport:

class LargeData {
final List<int> data = List.generate(1000000, (i) => i);
}
// With InMemory transport, this object is passed by reference
// No serialization/deserialization overhead!
final result = await service.processLargeData(LargeData());
  • Maximum Performance: No serialization overhead
  • Memory Efficient: Objects are not duplicated
  • Type Preservation: Full Dart type information maintained
  • Single-process applications
  • High-performance computing
  • Large data transfers
  • Development and testing

RPC Dart provides structured error handling:

// Server throws structured errors
if (request.b == 0) {
throw RpcException(
code: 'DIVISION_BY_ZERO',
message: 'Cannot divide by zero',
details: {'operand': 'b', 'value': 0},
);
}
// Client catches and handles
try {
final result = await calculator.divide(DivideRequest(10, 0));
} on RpcException catch (e) {
print('RPC Error: ${e.code} - ${e.message}');
print('Details: ${e.details}');
}

RPC Dart supports middleware for cross-cutting concerns:

// Logging middleware
class LoggingMiddleware extends RpcMiddleware {
@override
Future<T> call<T>(RpcCall call, RpcNext next) async {
print('Calling ${call.method}');
final result = await next(call);
print('Completed ${call.method}');
return result;
}
}
// Register middleware
responder.addMiddleware(LoggingMiddleware());
  1. Keep contracts simple: One responsibility per service
  2. Use meaningful names: Clear method and parameter names
  3. Version your contracts: Plan for API evolution
  4. Handle errors gracefully: Use structured error responses
  1. Choose appropriate transport: InMemory for single-process, HTTP for distributed
  2. Use streaming: For large datasets or real-time communication
  3. Implement timeouts: Set reasonable deadlines for requests
  4. Consider batching: Group related operations
  1. Use InMemory transport: For fast, isolated tests
  2. Mock services: Create test implementations of contracts
  3. Test error scenarios: Verify error handling works correctly
  4. Integration testing: Test with real transports

Now that you understand the core concepts, explore: