Repository: Tochemey/goakt
Pull request #371 (2024-06-19 20:56:50)

feat: add cluster client
closes https://github.com/Tochemey/goakt/issues/370

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/Tochemey/goakt/pull/371?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) Report
Attention: Patch coverage is `14.06250%` with `110 lines` in your changes missing coverage. Please review.
> Project coverage is 82.68%. Comparing base [(`350ec08`)](https://app.codecov.io/gh/Tochemey/goakt/commit/350ec08890351f86bbea83d315e78162377169f0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) to head [(`faa7f9c`)](https://app.codecov.io/gh/Tochemey/goakt/commit/faa7f9c4271a945aa42b4337612e786a8ca231ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene).

| [Files](https://app.codecov.io/gh/Tochemey/goakt/pull/371?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) | Patch % | Lines |
|---|---|---|
| [client/client.go](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&filepath=client%2Fclient.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene#diff-Y2xpZW50L2NsaWVudC5nbw==) | 0.00% | [68 Missing :warning: ](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) |
| [actors/actor\_system.go](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&filepath=actors%2Factor_system.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene#diff-YWN0b3JzL2FjdG9yX3N5c3RlbS5nbw==) | 14.28% | [24 Missing :warning: ](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) |
| [client/actor.go](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&filepath=client%2Factor.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene#diff-Y2xpZW50L2FjdG9yLmdv) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) |
| [client/random.go](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&filepath=client%2Frandom.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene#diff-Y2xpZW50L3JhbmRvbS5nbw==) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/Tochemey/goakt/pull/371?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #371      +/-   ##
==========================================
- Coverage   85.25%   82.68%   -2.58%     
==========================================
  Files          37       42       +5     
  Lines        3357     3470     +113     
==========================================
+ Hits         2862     2869       +7     
- Misses        374      482     +108     
+ Partials      121      119       -2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Tochemey/goakt/pull/371?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Arsene).


Patch:
diff --git a/.mockery.yaml b/.mockery.yaml
deleted file mode 100644
index f9c1da1..0000000
--- a/.mockery.yaml
+++ /dev/null
@@ -1,25 +0,0 @@
-quiet: False
-disable-version-string: True
-with-expecter: True
-exported: True
-filename: "{{ .InterfaceName | snakecase }}.go"
-outpkg: mocks
-packages:
-  github.com/tochemey/goakt/v2/hash:
-    config:
-    interfaces:
-      Hasher:
-        config:
-          dir: hash
-  github.com/tochemey/goakt/v2/discovery:
-    config:
-    interfaces:
-      Provider:
-        config:
-          dir: discovery
-  github.com/tochemey/goakt/v2/internal/cluster:
-    config:
-    interfaces:
-      Interface:
-        config:
-          dir: cluster
\ No newline at end of file
diff --git a/Earthfile b/Earthfile
index 8ae595d..fed516a 100644
--- a/Earthfile
+++ b/Earthfile
@@ -34,7 +34,9 @@ mock:
     FROM +code
 
     # generate the mocks
-    RUN mockery
+    RUN mockery  --dir hash --name Hasher --keeptree --exported=true --with-expecter=true --inpackage=true --disable-version-string=true --output ./mocks/hash --case snake
+    RUN mockery  --dir discovery --name Provider --keeptree --exported=true --with-expecter=true --inpackage=true --disable-version-string=true --output ./mocks/discovery --case snake
+    RUN mockery  --dir internal/cluster --name Interface --keeptree --exported=true --with-expecter=true --inpackage=true --disable-version-string=true --output ./mocks/cluster --case snake
 
     SAVE ARTIFACT ./mocks mocks AS LOCAL mocks
 
diff --git a/actors/actor_system.go b/actors/actor_system.go
index 1dfe5fa..03962e1 100644
--- a/actors/actor_system.go
+++ b/actors/actor_system.go
@@ -135,6 +135,7 @@ type ActorSystem interface {
 // Only a single instance of the ActorSystem can be created on a given node
 type actorSystem struct {
 	internalpbconnect.UnimplementedRemotingServiceHandler
+	internalpbconnect.UnimplementedClusterServiceHandler
 
 	// map of actors in the system
 	actors *pidMap
@@ -210,10 +211,11 @@ type actorSystem struct {
 	registry   types.Registry
 	reflection reflection
 
-	peersCacheMu       *sync.RWMutex
-	peersCache         map[string][]byte
-	clusterConfig      *ClusterConfig
-	redistributionChan chan *cluster.Event
+	peersStateLoopInterval time.Duration
+	peersCacheMu           *sync.RWMutex
+	peersCache             map[string][]byte
+	clusterConfig          *ClusterConfig
+	redistributionChan     chan *cluster.Event
 }
 
 // enforce compilation error when all methods of the ActorSystem interface are not implemented
@@ -230,28 +232,29 @@ func NewActorSystem(name string, opts ...Option) (ActorSystem, error) {
 	}
 
 	system := &actorSystem{
-		actors:              newPIDMap(1_000), // TODO need to check with memory footprint here since we change the map engine
-		actorsChan:          make(chan *internalpb.WireActor, 10),
-		name:                name,
-		logger:              log.DefaultLogger,
-		expireActorAfter:    DefaultPassivationTimeout,
-		replyTimeout:        DefaultReplyTimeout,
-		actorInitMaxRetries: DefaultInitMaxRetries,
-		supervisorStrategy:  DefaultSupervisoryStrategy,
-		telemetry:           telemetry.New(),
-		locker:              sync.Mutex{},
-		shutdownTimeout:     DefaultShutdownTimeout,
-		mailboxSize:         DefaultMailboxSize,
-		housekeeperStopSig:  make(chan types.Unit, 1),
-		eventsStream:        eventstream.New(),
-		partitionHasher:     hash.DefaultHasher(),
-		actorInitTimeout:    DefaultInitTimeout,
-		tracer:              noop.NewTracerProvider().Tracer(name),
-		clusterEventsChan:   make(chan *cluster.Event, 1),
-		registry:            types.NewRegistry(),
-		clusterSyncStopSig:  make(chan types.Unit, 1),
-		peersCacheMu:        &sync.RWMutex{},
-		peersCache:          make(map[string][]byte),
+		actors:                 newPIDMap(1_000), // TODO need to check with memory footprint here since we change the map engine
+		actorsChan:             make(chan *internalpb.WireActor, 10),
+		name:                   name,
+		logger:                 log.DefaultLogger,
+		expireActorAfter:       DefaultPassivationTimeout,
+		replyTimeout:           DefaultReplyTimeout,
+		actorInitMaxRetries:    DefaultInitMaxRetries,
+		supervisorStrategy:     DefaultSupervisoryStrategy,
+		telemetry:              telemetry.New(),
+		locker:                 sync.Mutex{},
+		shutdownTimeout:        DefaultShutdownTimeout,
+		mailboxSize:            DefaultMailboxSize,
+		housekeeperStopSig:     make(chan types.Unit, 1),
+		eventsStream:           eventstream.New(),
+		partitionHasher:        hash.DefaultHasher(),
+		actorInitTimeout:       DefaultInitTimeout,
+		tracer:                 noop.NewTracerProvider().Tracer(name),
+		clusterEventsChan:      make(chan *cluster.Event, 1),
+		registry:               types.NewRegistry(),
+		clusterSyncStopSig:     make(chan types.Unit, 1),
+		peersCacheMu:           &sync.RWMutex{},
+		peersCache:             make(map[string][]byte),
+		peersStateLoopInterval: DefaultPeerStateLoopInterval,
 	}
 
 	system.started.Store(false)
@@ -661,7 +664,7 @@ func (x *actorSystem) ActorOf(ctx context.Context, actorName string) (addr *goak
 	}
 
 	// check in the cluster
-	if x.cluster != nil || x.clusterEnabled.Load() {
+	if x.clusterEnabled.Load() {
 		actor, err := x.cluster.GetActor(spanCtx, actorName)
 		if err != nil {
 			if errors.Is(err, cluster.ErrActorNotFound) {
@@ -851,24 +854,41 @@ func (x *actorSystem) Stop(ctx context.Context) error {
 }
 
 // RemoteLookup for an actor on a remote host.
-func (x *actorSystem) RemoteLookup(_ context.Context, request *connect.Request[internalpb.RemoteLookupRequest]) (*connect.Response[internalpb.RemoteLookupResponse], error) {
+func (x *actorSystem) RemoteLookup(ctx context.Context, request *connect.Request[internalpb.RemoteLookupRequest]) (*connect.Response[internalpb.RemoteLookupResponse], error) {
 	logger := x.logger
+	msg := request.Msg
 
-	reqCopy := request.Msg
 	if !x.remotingEnabled.Load() {
 		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrRemotingDisabled)
 	}
 
-	actorPath := NewPath(reqCopy.GetName(), NewAddress(x.Name(), reqCopy.GetHost(), int(reqCopy.GetPort())))
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+	if remoteAddr != net.JoinHostPort(msg.GetHost(), strconv.Itoa(int(msg.GetPort()))) {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	if x.clusterEnabled.Load() {
+		actorName := msg.GetName()
+		actor, err := x.cluster.GetActor(ctx, actorName)
+		if err != nil {
+			if errors.Is(err, cluster.ErrActorNotFound) {
+				logger.Error(ErrAddressNotFound(actorName).Error())
+				return nil, ErrAddressNotFound(actorName)
+			}
+
+			return nil, connect.NewError(connect.CodeInternal, err)
+		}
+		return connect.NewResponse(&internalpb.RemoteLookupResponse{Address: actor.GetActorAddress()}), nil
+	}
+
+	actorPath := NewPath(msg.GetName(), NewAddress(x.Name(), msg.GetHost(), int(msg.GetPort())))
 	pid, exist := x.actors.get(actorPath)
 	if !exist {
 		logger.Error(ErrAddressNotFound(actorPath.String()).Error())
 		return nil, ErrAddressNotFound(actorPath.String())
 	}
 
-	addr := pid.ActorPath().RemoteAddress()
-
-	return connect.NewResponse(&internalpb.RemoteLookupResponse{Address: addr}), nil
+	return connect.NewResponse(&internalpb.RemoteLookupResponse{Address: pid.ActorPath().RemoteAddress()}), nil
 }
 
 // RemoteAsk is used to send a message to an actor remotely and expect a response
@@ -900,10 +920,15 @@ func (x *actorSystem) RemoteAsk(ctx context.Context, stream *connect.BidiStream[
 		}
 
 		message := request.GetRemoteMessage()
+		receiver := message.GetReceiver()
+		name := receiver.GetName()
 
-		name := message.GetReceiver().GetName()
-		actorPath := NewPath(name, NewAddress(x.name, x.remotingHost, int(x.remotingPort)))
+		remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+		if remoteAddr != net.JoinHostPort(receiver.GetHost(), strconv.Itoa(int(receiver.GetPort()))) {
+			return connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+		}
 
+		actorPath := NewPath(name, NewAddress(x.name, x.remotingHost, int(x.remotingPort)))
 		pid, exist := x.actors.get(actorPath)
 		if !exist {
 			logger.Error(ErrAddressNotFound(actorPath.String()).Error())
@@ -990,13 +1015,18 @@ func (x *actorSystem) RemoteTell(ctx context.Context, stream *connect.ClientStre
 func (x *actorSystem) RemoteReSpawn(ctx context.Context, request *connect.Request[internalpb.RemoteReSpawnRequest]) (*connect.Response[internalpb.RemoteReSpawnResponse], error) {
 	logger := x.logger
 
-	reqCopy := request.Msg
+	msg := request.Msg
 
 	if !x.remotingEnabled.Load() {
 		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrRemotingDisabled)
 	}
 
-	actorPath := NewPath(reqCopy.GetName(), NewAddress(x.Name(), reqCopy.GetHost(), int(reqCopy.GetPort())))
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+	if remoteAddr != net.JoinHostPort(msg.GetHost(), strconv.Itoa(int(msg.GetPort()))) {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	actorPath := NewPath(msg.GetName(), NewAddress(x.Name(), msg.GetHost(), int(msg.GetPort())))
 	pid, exist := x.actors.get(actorPath)
 	if !exist {
 		logger.Error(ErrAddressNotFound(actorPath.String()).Error())
@@ -1015,13 +1045,18 @@ func (x *actorSystem) RemoteReSpawn(ctx context.Context, request *connect.Reques
 func (x *actorSystem) RemoteStop(ctx context.Context, request *connect.Request[internalpb.RemoteStopRequest]) (*connect.Response[internalpb.RemoteStopResponse], error) {
 	logger := x.logger
 
-	reqCopy := request.Msg
+	msg := request.Msg
 
 	if !x.remotingEnabled.Load() {
 		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrRemotingDisabled)
 	}
 
-	actorPath := NewPath(reqCopy.GetName(), NewAddress(x.Name(), reqCopy.GetHost(), int(reqCopy.GetPort())))
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+	if remoteAddr != net.JoinHostPort(msg.GetHost(), strconv.Itoa(int(msg.GetPort()))) {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	actorPath := NewPath(msg.GetName(), NewAddress(x.Name(), msg.GetHost(), int(msg.GetPort())))
 	pid, exist := x.actors.get(actorPath)
 	if !exist {
 		logger.Error(ErrAddressNotFound(actorPath.String()).Error())
@@ -1040,31 +1075,79 @@ func (x *actorSystem) RemoteStop(ctx context.Context, request *connect.Request[i
 func (x *actorSystem) RemoteSpawn(ctx context.Context, request *connect.Request[internalpb.RemoteSpawnRequest]) (*connect.Response[internalpb.RemoteSpawnResponse], error) {
 	logger := x.logger
 
-	req := request.Msg
-
+	msg := request.Msg
 	if !x.remotingEnabled.Load() {
 		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrRemotingDisabled)
 	}
 
-	actor, err := x.reflection.ActorFrom(req.GetActorType())
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+	if remoteAddr != net.JoinHostPort(msg.GetHost(), strconv.Itoa(int(msg.GetPort()))) {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	actor, err := x.reflection.ActorFrom(msg.GetActorType())
 	if err != nil {
 		logger.Errorf("failed to create actor=[(%s) of type (%s)] on [host=%s, port=%d]: reason: (%v)",
-			req.GetActorName(), req.GetActorType(), req.GetHost(), req.GetPort(), err)
+			msg.GetActorName(), msg.GetActorType(), msg.GetHost(), msg.GetPort(), err)
+
 		if errors.Is(err, ErrTypeNotRegistered) {
 			return nil, connect.NewError(connect.CodeFailedPrecondition, ErrTypeNotRegistered)
 		}
+
 		return nil, connect.NewError(connect.CodeInternal, err)
 	}
 
-	_, err = x.Spawn(ctx, req.GetActorName(), actor)
-	if err != nil {
-		logger.Errorf("failed to create actor=(%s) on [host=%s, port=%d]: reason: (%v)", req.GetActorName(), req.GetHost(), req.GetPort(), err)
+	if _, err = x.Spawn(ctx, msg.GetActorName(), actor); err != nil {
+		logger.Errorf("failed to create actor=(%s) on [host=%s, port=%d]: reason: (%v)", msg.GetActorName(), msg.GetHost(), msg.GetPort(), err)
 		return nil, connect.NewError(connect.CodeInternal, err)
 	}
 
+	logger.Infof("actor=(%s) successfully created on [host=%s, port=%d]", msg.GetActorName(), msg.GetHost(), msg.GetPort())
 	return connect.NewResponse(new(internalpb.RemoteSpawnResponse)), nil
 }
 
+// GetNodeMetric handles the GetNodeMetric request send the given node
+func (x *actorSystem) GetNodeMetric(_ context.Context, request *connect.Request[internalpb.GetNodeMetricRequest]) (*connect.Response[internalpb.GetNodeMetricResponse], error) {
+	if !x.clusterEnabled.Load() {
+		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrClusterDisabled)
+	}
+
+	req := request.Msg
+
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+	if remoteAddr != req.GetNodeAddress() {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	actorCount := x.actors.len()
+	return connect.NewResponse(&internalpb.GetNodeMetricResponse{
+		NodeRemoteAddress: remoteAddr,
+		ActorsCount:       uint64(actorCount),
+	}), nil
+}
+
+// GetKinds returns the cluster kinds
+func (x *actorSystem) GetKinds(_ context.Context, request *connect.Request[internalpb.GetKindsRequest]) (*connect.Response[internalpb.GetKindsResponse], error) {
+	if !x.clusterEnabled.Load() {
+		return nil, connect.NewError(connect.CodeFailedPrecondition, ErrClusterDisabled)
+	}
+
+	req := request.Msg
+	remoteAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
+
+	// routine check
+	if remoteAddr != req.GetNodeAddress() {
+		return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidHost)
+	}
+
+	kinds := make([]string, len(x.clusterConfig.Kinds()))
+	for i, kind := range x.clusterConfig.Kinds() {
+		kinds[i] = types.NameOf(kind)
+	}
+
+	return connect.NewResponse(&internalpb.GetKindsResponse{Kinds: kinds}), nil
+}
+
 // handleRemoteAsk handles a synchronous message to another actor and expect a response.
 // This block until a response is received or timed out.
 func (x *actorSystem) handleRemoteAsk(ctx context.Context, to PID, message proto.Message) (response proto.Message, err error) {
@@ -1176,9 +1259,12 @@ func (x *actorSystem) enableRemoting(ctx context.Context) {
 	x.remotingHost = remotingHost
 	x.remotingPort = int32(remotingPort)
 
+	remotingServicePath, remotingServiceHandler := internalpbconnect.NewRemotingServiceHandler(x, opts...)
+	clusterServicePath, clusterServiceHandler := internalpbconnect.NewClusterServiceHandler(x, opts...)
+
 	mux := http.NewServeMux()
-	path, handler := internalpbconnect.NewRemotingServiceHandler(x, opts...)
-	mux.Handle(path, handler)
+	mux.Handle(remotingServicePath, remotingServiceHandler)
+	mux.Handle(clusterServicePath, clusterServiceHandler)
 	serverAddr := fmt.Sprintf("%s:%d", x.remotingHost, x.remotingPort)
 
 	// TODO revisit the timeouts
@@ -1314,7 +1400,7 @@ func (x *actorSystem) clusterEventsLoop() {
 // peersStateLoop fetches the cluster peers' PeerState and update the node peersCache
 func (x *actorSystem) peersStateLoop() {
 	x.logger.Info("peers state synchronization has started...")
-	ticker := time.NewTicker(10 * time.Second)
+	ticker := time.NewTicker(x.peersStateLoopInterval)
 	tickerStopSig := make(chan types.Unit, 1)
 	go func() {
 		for {
diff --git a/actors/cluster_config.go b/actors/cluster_config.go
index fbc82a5..17d5c81 100644
--- a/actors/cluster_config.go
+++ b/actors/cluster_config.go
@@ -46,7 +46,7 @@ var _ validation.Validator = (*ClusterConfig)(nil)
 // NewClusterConfig creates an instance of ClusterConfig
 func NewClusterConfig() *ClusterConfig {
 	return &ClusterConfig{
-		kinds:              []Actor{new(fnActor)},
+		kinds:              []Actor{new(funcActor)},
 		minimumPeersQuorum: 1,
 		replicaCount:       2,
 	}
diff --git a/actors/errors.go b/actors/errors.go
index 29c93af..b71c9ed 100644
--- a/actors/errors.go
+++ b/actors/errors.go
@@ -85,6 +85,8 @@ var (
 	ErrKindsRequired = errors.New("actor kinds are required")
 	// ErrUndefinedTask is returned when piping a long-running task result to an actor
 	ErrUndefinedTask = errors.New("task is not defined")
+	// ErrInvalidHost is returned when a request is sent to an invalid host
+	ErrInvalidHost = errors.New("invalid host")
 )
 
 // IsEOF returns true if the given error is an EOF error
diff --git a/actors/fn.go b/actors/func_actor.go
similarity index 85%
rename from actors/fn.go
rename to actors/func_actor.go
index 1fb0fa5..4cbe180 100644
--- a/actors/fn.go
+++ b/actors/func_actor.go
@@ -44,35 +44,35 @@ type PostStopFunc = func(ctx context.Context) error
 // FuncOption is the interface that applies a SpawnHook option.
 type FuncOption interface {
 	// Apply sets the Option value of a config.
-	Apply(actor *fnActor)
+	Apply(actor *funcActor)
 }
 
 var _ FuncOption = funcOption(nil)
 
 // funcOption implements the FuncOption interface.
-type funcOption func(*fnActor)
+type funcOption func(*funcActor)
 
 // Apply implementation
-func (f funcOption) Apply(c *fnActor) {
+func (f funcOption) Apply(c *funcActor) {
 	f(c)
 }
 
 // WithPreStart defines the PreStartFunc hook
 func WithPreStart(fn PreStartFunc) FuncOption {
-	return funcOption(func(actor *fnActor) {
+	return funcOption(func(actor *funcActor) {
 		actor.preStart = fn
 	})
 }
 
 // WithPostStop defines the PostStopFunc hook
 func WithPostStop(fn PostStopFunc) FuncOption {
-	return funcOption(func(actor *fnActor) {
+	return funcOption(func(actor *funcActor) {
 		actor.postStop = fn
 	})
 }
 
-// fnActor is an actor that only handles messages
-type fnActor struct {
+// funcActor is an actor that only handles messages
+type funcActor struct {
 	pid         PID
 	id          string
 	receiveFunc ReceiveFunc
@@ -80,10 +80,10 @@ type fnActor struct {
 	postStop    PostStopFunc
 }
 
-// newFnActor creates an instance of fnActor
+// newFnActor creates an instance of funcActor
 func newFnActor(id string, receiveFunc ReceiveFunc, opts ...FuncOption) Actor {
 	// create the actor instance
-	actor := &fnActor{
+	actor := &funcActor{
 		receiveFunc: receiveFunc,
 		id:          id,
 	}
@@ -95,10 +95,10 @@ func newFnActor(id string, receiveFunc ReceiveFunc, opts ...FuncOption) Actor {
 }
 
 // enforce compilation error
-var _ Actor = (*fnActor)(nil)
+var _ Actor = (*funcActor)(nil)
 
 // PreStart pre-starts the actor.
-func (x fnActor) PreStart(ctx context.Context) error {
+func (x funcActor) PreStart(ctx context.Context) error {
 	// check whether the pre-start hook is set and call it
 	preStart := x.preStart
 	if preStart != nil {
@@ -108,7 +108,7 @@ func (x fnActor) PreStart(ctx context.Context) error {
 }
 
 // Receive processes any message dropped into the actor mailbox.
-func (x fnActor) Receive(ctx ReceiveContext) {
+func (x funcActor) Receive(ctx ReceiveContext) {
 	switch m := ctx.Message().(type) {
 	case *goaktpb.PostStart:
 		x.pid = ctx.Self()
@@ -121,7 +121,7 @@ func (x fnActor) Receive(ctx ReceiveContext) {
 }
 
 // PostStop is executed when the actor is shutting down.
-func (x fnActor) PostStop(ctx context.Context) error {
+func (x funcActor) PostStop(ctx context.Context) error {
 	// check whether the pre-start hook is set and call it
 	postStop := x.postStop
 	if postStop != nil {
diff --git a/actors/option.go b/actors/option.go
index 4ac3fb9..6f2b963 100644
--- a/actors/option.go
+++ b/actors/option.go
@@ -193,3 +193,10 @@ func WithMetric() Option {
 		system.metricEnabled.Store(true)
 	})
 }
+
+// WithPeerStateLoopInterval sets the peer state loop interval
+func WithPeerStateLoopInterval(interval time.Duration) Option {
+	return OptionFunc(func(system *actorSystem) {
+		system.peersStateLoopInterval = interval
+	})
+}
diff --git a/actors/types.go b/actors/types.go
index df26862..790c5aa 100644
--- a/actors/types.go
+++ b/actors/types.go
@@ -55,6 +55,8 @@ const (
 	DefaultInitTimeout = time.Second
 	// DefaultMailboxSize defines the default mailbox size
 	DefaultMailboxSize = 1_000_000
+	// DefaultPeerStateLoopInterval defines the default peer state loop interval
+	DefaultPeerStateLoopInterval = 10 * time.Second
 	// DefaultStashCapacity defines the default stash size
 	DefaultStashCapacity = 1_000
 	eventsTopic          = "topic.events"
diff --git a/client/actor.go b/client/actor.go
new file mode 100644
index 0000000..8b03576
--- /dev/null
+++ b/client/actor.go
@@ -0,0 +1,54 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+// Actor defines a given actor name and kind
+type Actor struct {
+	name string // Name defines the actor name. This will be unique in the Client
+	kind string // Kind specifies the actor kind.
+}
+
+// NewActor creates an instance of Actor
+func NewActor(kind string) *Actor {
+	return &Actor{
+		kind: kind,
+	}
+}
+
+// WithName set the given name
+func (x *Actor) WithName(name string) *Actor {
+	x.name = name
+	return x
+}
+
+// Name returns the actor name
+func (x *Actor) Name() string {
+	return x.name
+}
+
+// Kind returns the actor kind
+func (x *Actor) Kind() string {
+	return x.kind
+}
diff --git a/client/balancer.go b/client/balancer.go
new file mode 100644
index 0000000..e488fe4
--- /dev/null
+++ b/client/balancer.go
@@ -0,0 +1,46 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+// BalancerStrategy defines the Client weight balancer strategy
+type BalancerStrategy int
+
+const (
+	// RoundRobinStrategy uses the round-robin algorithm to determine the appropriate node
+	RoundRobinStrategy BalancerStrategy = iota
+	// RandomStrategy uses the random algorithm to determine the appropriate node
+	RandomStrategy
+	// LeastLoadStrategy choses among a pool of nodes the node that has the less weight
+	// at the time of the execution
+	LeastLoadStrategy
+)
+
+// Balancer helps locate the right node to channel Client request to
+type Balancer interface {
+	// Set sets the balancer nodes pool
+	Set(nodes ...*Node)
+	// Next returns the appropriate weight-balanced node to use
+	Next() *Node
+}
diff --git a/client/client.go b/client/client.go
new file mode 100644
index 0000000..d86f4e9
--- /dev/null
+++ b/client/client.go
@@ -0,0 +1,301 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"context"
+	"fmt"
+	"net"
+	sdhttp "net/http"
+	"strconv"
+	"sync"
+	"time"
+
+	"connectrpc.com/connect"
+	"connectrpc.com/otelconnect"
+	"google.golang.org/protobuf/proto"
+
+	"github.com/tochemey/goakt/v2/actors"
+	"github.com/tochemey/goakt/v2/goaktpb"
+	"github.com/tochemey/goakt/v2/internal/http"
+	"github.com/tochemey/goakt/v2/internal/internalpb"
+	"github.com/tochemey/goakt/v2/internal/internalpb/internalpbconnect"
+	"github.com/tochemey/goakt/v2/internal/types"
+	"github.com/tochemey/goakt/v2/internal/validation"
+)
+
+// Client connects to af Go-Akt nodes.
+// This client can only be used when remoting is enabled on the various nodes.
+// The client is only used against a Go-Akt cluster
+type Client struct {
+	nodes           []*Node
+	locker          *sync.Mutex
+	strategy        BalancerStrategy
+	balancer        Balancer
+	closeSignal     chan types.Unit
+	refreshInterval time.Duration
+	client          *sdhttp.Client
+}
+
+// New creates an instance of Client. The provided nodes are the cluster nodes.
+// A node is the form of host:port where host and port represents the remoting host
+// and remoting port of the nodes. The nodes list will be load balanced based upon the load-balancing
+// strategy defined by default round-robin will be used.
+// An instance of the Client can be reused and it is thread safe.
+func New(ctx context.Context, addresses []string, opts ...Option) (*Client, error) {
+	chain := validation.
+		New(validation.FailFast()).
+		AddAssertion(len(addresses) != 0, "addresses are required")
+	for _, host := range addresses {
+		chain = chain.AddValidator(validation.NewTCPAddressValidator(host))
+	}
+
+	if err := chain.Validate(); err != nil {
+		return nil, err
+	}
+
+	cl := &Client{
+		locker:          &sync.Mutex{},
+		strategy:        RoundRobinStrategy,
+		refreshInterval: -1,
+		client:          http.NewClient(),
+	}
+
+	// apply the various options
+	for _, opt := range opts {
+		opt.Apply(cl)
+	}
+
+	var nodes []*Node
+	for _, address := range addresses {
+		weight, ok, err := cl.getNodeMetric(ctx, address)
+		if err != nil {
+			return nil, err
+		}
+
+		if !ok {
+			continue
+		}
+
+		nodes = append(nodes, NewNode(address, weight))
+	}
+
+	switch cl.strategy {
+	case RoundRobinStrategy:
+		cl.balancer = NewRoundRobin()
+	case RandomStrategy:
+		cl.balancer = NewRandom()
+	case LeastLoadStrategy:
+		cl.balancer = NewLeastLoad()
+	default:
+		// TODO: add more balancer strategy
+	}
+
+	cl.nodes = nodes
+	cl.balancer.Set(cl.nodes...)
+
+	// only refresh addresses when refresh interval is set
+	if cl.refreshInterval > 0 {
+		cl.closeSignal = make(chan types.Unit, 1)
+		go cl.refreshNodesLoop()
+	}
+
+	return cl, nil
+}
+
+// Close closes the Client connection
+func (x *Client) Close() {
+	x.locker.Lock()
+	x.nodes = make([]*Node, 0)
+	if x.refreshInterval > 0 {
+		close(x.closeSignal)
+	}
+	x.client.CloseIdleConnections()
+	x.locker.Unlock()
+}
+
+// Kinds returns the list of all the Client kinds registered
+func (x *Client) Kinds(ctx context.Context) ([]string, error) {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	interceptor, err := otelconnect.NewInterceptor()
+	if err != nil {
+		return nil, err
+	}
+
+	host, port := x.getNextRemotingHostAndPort()
+	service := internalpbconnect.NewClusterServiceClient(
+		http.NewClient(),
+		http.URL(host, port),
+		connect.WithInterceptors(interceptor))
+
+	response, err := service.GetKinds(ctx, connect.NewRequest(&internalpb.GetKindsRequest{
+		NodeAddress: fmt.Sprintf("%s:%d", host, port),
+	}))
+	if err != nil {
+		return nil, err
+	}
+
+	return response.Msg.GetKinds(), nil
+}
+
+// Spawn creates an actor provided the actor name.
+// The actor name will be generated and returned when the request is successful
+func (x *Client) Spawn(ctx context.Context, actor *Actor) (err error) {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	host, port := x.getNextRemotingHostAndPort()
+	return actors.RemoteSpawn(ctx, host, port, actor.Name(), actor.Kind())
+}
+
+// Tell sends a message to a given actor provided the actor name.
+// If the given actor does not exist it will be created automatically when
+// Client mode is enabled
+func (x *Client) Tell(ctx context.Context, actor *Actor, message proto.Message) error {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+
+	remoteHost, remotePort := x.getNextRemotingHostAndPort()
+	// lookup the actor address
+	address, err := actors.RemoteLookup(ctx, remoteHost, remotePort, actor.Name())
+	if err != nil {
+		return err
+	}
+
+	// no address found
+	if address == nil || proto.Equal(address, new(goaktpb.Address)) {
+		return actors.ErrActorNotFound(actor.Name())
+	}
+
+	return actors.RemoteTell(ctx, address, message)
+}
+
+// Ask sends a message to a given actor provided the actor name and expects a response.
+// If the given actor does not exist it will be created automatically when
+// Client mode is enabled. This will block until a response is received or timed out.
+func (x *Client) Ask(ctx context.Context, actor *Actor, message proto.Message) (reply proto.Message, err error) {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+
+	remoteHost, remotePort := x.getNextRemotingHostAndPort()
+	// lookup the actor address
+	address, err := actors.RemoteLookup(ctx, remoteHost, remotePort, actor.Name())
+	if err != nil {
+		return nil, err
+	}
+
+	// no address found
+	if address == nil || proto.Equal(address, new(goaktpb.Address)) {
+		return nil, actors.ErrActorNotFound(actor.Name())
+	}
+
+	response, err := actors.RemoteAsk(ctx, address, message)
+	if err != nil {
+		return nil, err
+	}
+
+	return response.UnmarshalNew()
+}
+
+// Kill kills a given actor in the Client
+func (x *Client) Kill(ctx context.Context, actor *Actor) error {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	host, port := x.getNextRemotingHostAndPort()
+	return actors.RemoteStop(ctx, host, port, actor.Name())
+}
+
+// getNextRemotingHostAndPort returns the next node host and port
+func (x *Client) getNextRemotingHostAndPort() (host string, port int) {
+	node := x.balancer.Next()
+	host, p, _ := net.SplitHostPort(node.Address())
+	port, _ = strconv.Atoi(p)
+	return
+}
+
+// updateNodes updates the list of nodes availables in the pool
+// the old nodes pool is completely replaced by the new nodes pool
+func (x *Client) updateNodes(ctx context.Context) error {
+	x.locker.Unlock()
+	defer x.locker.Lock()
+
+	for _, node := range x.nodes {
+		weight, ok, err := x.getNodeMetric(ctx, node.Address())
+		if err != nil {
+			return err
+		}
+		if !ok {
+			continue
+		}
+		node.SetWeight(float64(weight))
+	}
+	return nil
+}
+
+// refreshNodesLoop refreshes the nodes
+func (x *Client) refreshNodesLoop() {
+	ticker := time.NewTicker(x.refreshInterval)
+	tickerStopSig := make(chan types.Unit, 1)
+	go func() {
+		for {
+			select {
+			case <-ticker.C:
+				if err := x.updateNodes(context.Background()); err != nil {
+					// TODO: is it good to panic?
+					panic(err)
+				}
+			case <-x.closeSignal:
+				tickerStopSig <- types.Unit{}
+				return
+			}
+		}
+	}()
+	<-tickerStopSig
+	ticker.Stop()
+}
+
+// getNodeMetric pings a given node and get the node metric info and
+func (x *Client) getNodeMetric(ctx context.Context, node string) (int, bool, error) {
+	host, p, _ := net.SplitHostPort(node)
+	port, _ := strconv.Atoi(p)
+	service := internalpbconnect.NewClusterServiceClient(
+		x.client,
+		http.URL(host, port))
+
+	response, err := service.GetNodeMetric(ctx, connect.NewRequest(&internalpb.GetNodeMetricRequest{NodeAddress: node}))
+	if err != nil {
+		code := connect.CodeOf(err)
+
+		// here node may not be available
+		if code == connect.CodeUnavailable ||
+			code == connect.CodeCanceled ||
+			code == connect.CodeDeadlineExceeded {
+			return 0, false, nil
+		}
+
+		return 0, false, err
+	}
+	return int(response.Msg.GetActorsCount()), true, nil
+}
diff --git a/client/least_load.go b/client/least_load.go
new file mode 100644
index 0000000..1c4668c
--- /dev/null
+++ b/client/least_load.go
@@ -0,0 +1,65 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"cmp"
+	"slices"
+	"sync"
+)
+
+// LeastLoad uses the LeastLoadStrategy to pick the next available node
+// in the pool
+type LeastLoad struct {
+	locker *sync.Mutex
+	nodes  []*Node
+}
+
+var _ Balancer = (*LeastLoad)(nil)
+
+// NewLeastLoad creates an instance of LeastLoad
+func NewLeastLoad() *LeastLoad {
+	return &LeastLoad{
+		nodes:  make([]*Node, 0),
+		locker: &sync.Mutex{},
+	}
+}
+
+// Set sets the balancer nodes pool
+func (x *LeastLoad) Set(nodes ...*Node) {
+	x.locker.Lock()
+	x.nodes = nodes
+	x.locker.Unlock()
+}
+
+// Next returns the next node in the pool
+func (x *LeastLoad) Next() *Node {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	slices.SortStableFunc(x.nodes, func(a, b *Node) int {
+		return cmp.Compare(a.Weight(), b.Weight())
+	})
+	return x.nodes[0]
+}
diff --git a/client/node.go b/client/node.go
new file mode 100644
index 0000000..efbadae
--- /dev/null
+++ b/client/node.go
@@ -0,0 +1,67 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import "sync"
+
+// Node represents the node in the cluster
+type Node struct {
+	address string
+	weight  float64
+	mutex   *sync.Mutex
+}
+
+// NewNode creates an instance of Node
+func NewNode(address string, weight int) *Node {
+	return &Node{
+		address: address,
+		weight:  float64(weight),
+		mutex:   &sync.Mutex{},
+	}
+}
+
+// SetWeight sets the node weight.
+// This is thread safe
+func (n *Node) SetWeight(weight float64) {
+	n.mutex.Lock()
+	n.weight = weight
+	n.mutex.Unlock()
+}
+
+// Address returns the node address
+func (n *Node) Address() string {
+	n.mutex.Lock()
+	address := n.address
+	n.mutex.Unlock()
+	return address
+}
+
+// Weight returns the node weight
+func (n *Node) Weight() float64 {
+	n.mutex.Lock()
+	load := n.weight
+	n.mutex.Unlock()
+	return load
+}
diff --git a/client/option.go b/client/option.go
new file mode 100644
index 0000000..5da7a8e
--- /dev/null
+++ b/client/option.go
@@ -0,0 +1,58 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import "time"
+
+// Option is the interface that applies a configuration option.
+type Option interface {
+	// Apply sets the Option value of a config.
+	Apply(cl *Client)
+}
+
+// enforce compilation error
+var _ Option = OptionFunc(nil)
+
+// OptionFunc implements the Option interface.
+type OptionFunc func(*Client)
+
+func (f OptionFunc) Apply(c *Client) {
+	f(c)
+}
+
+// WithBalancerStrategy sets the Client weight balancer strategy
+func WithBalancerStrategy(strategy BalancerStrategy) Option {
+	return OptionFunc(func(c *Client) {
+		c.strategy = strategy
+	})
+}
+
+// WithRefresh sets a refresh interval. This help check the nodes state
+// time to time. This help remove dead nodes from the pool
+func WithRefresh(interval time.Duration) Option {
+	return OptionFunc(func(c *Client) {
+		c.refreshInterval = interval
+	})
+}
diff --git a/client/random.go b/client/random.go
new file mode 100644
index 0000000..fcb32d1
--- /dev/null
+++ b/client/random.go
@@ -0,0 +1,63 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"math/rand"
+	"sync"
+	"time"
+)
+
+// Random helps pick a node at random
+type Random struct {
+	locker *sync.Mutex
+	nodes  []*Node
+	rnd    *rand.Rand
+}
+
+var _ Balancer = (*Random)(nil)
+
+// NewRandom creates an instance of Random balancer
+func NewRandom() *Random {
+	return &Random{
+		nodes:  make([]*Node, 0),
+		locker: &sync.Mutex{},
+		rnd:    rand.New(rand.NewSource(time.Now().UTC().UnixNano())), //nolint:gosec
+	}
+}
+
+// Set sets the balancer nodes pool
+func (x *Random) Set(nodes ...*Node) {
+	x.locker.Lock()
+	x.nodes = nodes
+	x.locker.Unlock()
+}
+
+// Next returns the next node in the pool
+func (x *Random) Next() *Node {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	return x.nodes[x.rnd.Intn(len(x.nodes))]
+}
diff --git a/client/round_robin.go b/client/round_robin.go
new file mode 100644
index 0000000..f37e89f
--- /dev/null
+++ b/client/round_robin.go
@@ -0,0 +1,63 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"sync"
+	"sync/atomic"
+)
+
+// RoundRobin implements the round-robin algorithm
+// to pick a particular nodes in the cluster
+type RoundRobin struct {
+	locker *sync.Mutex
+	nodes  []*Node
+	next   uint32
+}
+
+var _ Balancer = (*RoundRobin)(nil)
+
+// NewRoundRobin creates an instance of RoundRobin
+func NewRoundRobin() *RoundRobin {
+	return &RoundRobin{
+		nodes:  make([]*Node, 0),
+		locker: &sync.Mutex{},
+	}
+}
+
+// Set sets the balancer nodes pool
+func (x *RoundRobin) Set(nodes ...*Node) {
+	x.locker.Lock()
+	x.nodes = nodes
+	x.locker.Unlock()
+}
+
+// Next returns the next node in the pool
+func (x *RoundRobin) Next() *Node {
+	x.locker.Lock()
+	defer x.locker.Unlock()
+	n := atomic.AddUint32(&x.next, 1)
+	return x.nodes[(int(n)-1)%len(x.nodes)]
+}
diff --git a/discovery/nats/discovery.go b/discovery/nats/discovery.go
index 41d1c43..a5e5c08 100644
--- a/discovery/nats/discovery.go
+++ b/discovery/nats/discovery.go
@@ -120,9 +120,7 @@ func (d *Discovery) Initialize() error {
 
 	// let us connect using an exponential backoff mechanism
 	// we will attempt to connect 5 times to see whether there have started successfully or not.
-	const (
-		maxRetries = 5
-	)
+	const maxRetries = 5
 	// create a new instance of retrier that will try a maximum of five times, with
 	// an initial delay of 100 ms and a maximum delay of opts.ReconnectWait
 	retrier := retry.NewRetrier(maxRetries, 100*time.Millisecond, opts.ReconnectWait)
diff --git a/discovery/node.go b/discovery/node.go
index 82f6149..6a19e4d 100644
--- a/discovery/node.go
+++ b/discovery/node.go
@@ -25,6 +25,7 @@
 package discovery
 
 import (
+	"fmt"
 	"net"
 	"strconv"
 )
@@ -44,11 +45,16 @@ type Node struct {
 }
 
 // PeersAddress returns address the node's peers will use to connect to
-func (n Node) PeersAddress() string {
+func (n *Node) PeersAddress() string {
 	return net.JoinHostPort(n.Host, strconv.Itoa(n.PeersPort))
 }
 
 // GossipAddress returns the node discovery address
-func (n Node) GossipAddress() string {
+func (n *Node) GossipAddress() string {
 	return net.JoinHostPort(n.Host, strconv.Itoa(n.GossipPort))
 }
+
+// String returns the printable representation of Node
+func (n *Node) String() string {
+	return fmt.Sprintf("[name=%s host=%s gossip=%d  peers=%d remoting=%d]", n.Name, n.Host, n.GossipPort, n.PeersPort, n.RemotingPort)
+}
diff --git a/discovery/static/config.go b/discovery/static/config.go
index 19f2645..647c3a4 100644
--- a/discovery/static/config.go
+++ b/discovery/static/config.go
@@ -25,18 +25,9 @@
 package static
 
 import (
-	"fmt"
-	"net"
-	"strconv"
-	"strings"
-
-	"github.com/pkg/errors"
-
 	"github.com/tochemey/goakt/v2/internal/validation"
 )
 
-var errFmt = "invalid address=(%s)"
-
 // Config represents the static discovery provider configuration
 type Config struct {
 	// Hosts defines the list of hosts in the form of ip:port where the port is the  gossip port.
@@ -50,40 +41,8 @@ func (x Config) Validate() error {
 		AddAssertion(len(x.Hosts) != 0, "hosts are required")
 
 	for _, host := range x.Hosts {
-		chain = chain.AddValidator(newAddressValidator(host))
+		chain = chain.AddValidator(validation.NewTCPAddressValidator(host))
 	}
 
 	return chain.Validate()
 }
-
-type addressValidator struct {
-	address string
-}
-
-// making sure the given struct implements the given interface
-var _ validation.Validator = (*addressValidator)(nil)
-
-func newAddressValidator(address string) *addressValidator {
-	return &addressValidator{address: address}
-}
-
-// Validate implements validation.Validator.
-func (a *addressValidator) Validate() error {
-	host, port, err := net.SplitHostPort(strings.TrimSpace(a.address))
-	if err != nil {
-		return errors.Wrapf(err, errFmt, a.address)
-	}
-
-	// let us validate the port number
-	portNum, err := strconv.Atoi(port)
-	if err != nil {
-		return errors.Wrapf(err, errFmt, a.address)
-	}
-
-	// TODO: maybe we only need to check port number not to be negative
-	if host == "" || portNum > 65535 || portNum < 1 {
-		return fmt.Errorf(errFmt, a.address)
-	}
-
-	return nil
-}
diff --git a/internal/cluster/engine.go b/internal/cluster/engine.go
index ab8fec6..823c5a6 100644
--- a/internal/cluster/engine.go
+++ b/internal/cluster/engine.go
@@ -29,6 +29,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"slices"
 	"strconv"
 	"time"
 
@@ -377,21 +378,14 @@ func (n *Engine) PutActor(ctx context.Context, actor *internalpb.WireActor) erro
 	})
 
 	eg.Go(func() error {
-		// avoid duplicate actors
-		incomingPath := actor.GetActorPath()
-		exist := false
-		for _, actor := range n.peerState.GetActors() {
-			path := actor.GetActorPath()
-			if path == incomingPath {
-				exist = true
-				break
-			}
-		}
-
-		if !exist {
-			n.peerState.Actors = append(n.peerState.GetActors(), actor)
-		}
-
+		// append to the existing list
+		actors := append(n.peerState.GetActors(), actor)
+		// remove duplicates
+		compacted := slices.CompactFunc(actors, func(actor *internalpb.WireActor, actor2 *internalpb.WireActor) bool {
+			return proto.Equal(actor, actor2)
+		})
+
+		n.peerState.Actors = compacted
 		encoded, _ := proto.Marshal(n.peerState)
 		if err := n.dmap.Put(ctx, n.host.PeersAddress(), encoded); err != nil {
 			return fmt.Errorf("failed to sync peer=(%s) request: %v", n.host.PeersAddress(), err)
@@ -416,31 +410,31 @@ func (n *Engine) GetState(ctx context.Context, peerAddress string) (*internalpb.
 
 	logger := n.logger
 
-	logger.Infof("retrieving peer (%s) sync record", peerAddress)
+	logger.Infof("[%s] retrieving peer (%s) sync record", n.host.PeersAddress(), peerAddress)
 	resp, err := n.dmap.Get(ctx, peerAddress)
 	if err != nil {
 		if errors.Is(err, olric.ErrKeyNotFound) {
-			logger.Warnf("peer=(%s) sync record is not found", peerAddress)
+			logger.Warnf("[%s] has not found peer=(%s) sync record", n.host.PeersAddress(), peerAddress)
 			return nil, ErrPeerSyncNotFound
 		}
 
-		logger.Errorf("failed to find peer=(%s) sync record: %v", peerAddress, err)
+		logger.Errorf("[%s] failed to find peer=(%s) sync record: %v", n.host.PeersAddress(), peerAddress, err)
 		return nil, err
 	}
 
 	bytea, err := resp.Byte()
 	if err != nil {
-		logger.Errorf("failed to read peer=(%s) sync record: %v", err)
+		logger.Errorf("[%s] failed to read peer=(%s) sync record: %v", n.host.PeersAddress(), peerAddress, err)
 		return nil, err
 	}
 
 	peerState := new(internalpb.PeerState)
 	if err := proto.Unmarshal(bytea, peerState); err != nil {
-		logger.Errorf("failed to decode peer=(%s) sync record: %v", err)
+		logger.Errorf("[%s] failed to decode peer=(%s) sync record: %v", n.host.PeersAddress(), peerAddress, err)
 		return nil, err
 	}
 
-	logger.Infof("peer (%s) sync record successfully retrieved.🎉", peerAddress)
+	logger.Infof("[%s] successfully retrieved peer (%s) sync record .🎉", n.host.PeersAddress(), peerAddress)
 	return peerState, nil
 }
 
@@ -451,31 +445,31 @@ func (n *Engine) GetActor(ctx context.Context, actorName string) (*internalpb.Wi
 
 	logger := n.logger
 
-	logger.Infof("retrieving actor (%s) from the cluster", actorName)
+	logger.Infof("[%s] retrieving actor (%s) from the cluster", n.host.PeersAddress(), actorName)
 
 	resp, err := n.dmap.Get(ctx, actorName)
 	if err != nil {
 		if errors.Is(err, olric.ErrKeyNotFound) {
-			logger.Warnf("actor=%s is not found in the cluster", actorName)
+			logger.Warnf("[%s] could not find actor=%s the cluster", n.host.PeersAddress(), actorName)
 			return nil, ErrActorNotFound
 		}
-		logger.Error(errors.Wrapf(err, "failed to get actor=%s record", actorName))
+		logger.Error(errors.Wrapf(err, "[%s] failed to get actor=%s record", n.host.PeersAddress(), actorName))
 		return nil, err
 	}
 
 	bytea, err := resp.Byte()
 	if err != nil {
-		logger.Error(errors.Wrapf(err, "failed to read the record at:{%s}", actorName))
+		logger.Error(errors.Wrapf(err, "[%s] failed to read the record at:{%s}", n.host.PeersAddress(), actorName))
 		return nil, err
 	}
 
 	actor, err := decode(bytea)
 	if err != nil {
-		logger.Error(errors.Wrapf(err, "failed to decode actor=%s record", actorName))
+		logger.Error(errors.Wrapf(err, "[%s] failed to decode actor=%s record", n.host.PeersAddress(), actorName))
 		return nil, err
 	}
 
-	logger.Infof("actor (%s) successfully retrieved from the cluster", actor.GetActorName())
+	logger.Infof("[%s] successfully retrieved from the cluster actor (%s)", n.host.PeersAddress(), actor.GetActorName())
 	return actor, nil
 }
 
diff --git a/internal/internalpb/cluster.pb.go b/internal/internalpb/cluster.pb.go
new file mode 100644
index 0000000..528a723
--- /dev/null
+++ b/internal/internalpb/cluster.pb.go
@@ -0,0 +1,374 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.33.0
+// 	protoc        (unknown)
+// source: internal/cluster.proto
+
+package internalpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type GetNodeMetricRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Specifies the node address
+	NodeAddress string `protobuf:"bytes,1,opt,name=node_address,json=nodeAddress,proto3" json:"node_address,omitempty"`
+}
+
+func (x *GetNodeMetricRequest) Reset() {
+	*x = GetNodeMetricRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_internal_cluster_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetNodeMetricRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetNodeMetricRequest) ProtoMessage() {}
+
+func (x *GetNodeMetricRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_cluster_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetNodeMetricRequest.ProtoReflect.Descriptor instead.
+func (*GetNodeMetricRequest) Descriptor() ([]byte, []int) {
+	return file_internal_cluster_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetNodeMetricRequest) GetNodeAddress() string {
+	if x != nil {
+		return x.NodeAddress
+	}
+	return ""
+}
+
+type GetNodeMetricResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Specifies the node address
+	NodeRemoteAddress string `protobuf:"bytes,1,opt,name=node_remote_address,json=nodeRemoteAddress,proto3" json:"node_remote_address,omitempty"`
+	// Specifies the actors count for the given node
+	ActorsCount uint64 `protobuf:"varint,2,opt,name=actors_count,json=actorsCount,proto3" json:"actors_count,omitempty"`
+}
+
+func (x *GetNodeMetricResponse) Reset() {
+	*x = GetNodeMetricResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_internal_cluster_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetNodeMetricResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetNodeMetricResponse) ProtoMessage() {}
+
+func (x *GetNodeMetricResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_cluster_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetNodeMetricResponse.ProtoReflect.Descriptor instead.
+func (*GetNodeMetricResponse) Descriptor() ([]byte, []int) {
+	return file_internal_cluster_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GetNodeMetricResponse) GetNodeRemoteAddress() string {
+	if x != nil {
+		return x.NodeRemoteAddress
+	}
+	return ""
+}
+
+func (x *GetNodeMetricResponse) GetActorsCount() uint64 {
+	if x != nil {
+		return x.ActorsCount
+	}
+	return 0
+}
+
+type GetKindsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Specifies the node address
+	NodeAddress string `protobuf:"bytes,1,opt,name=node_address,json=nodeAddress,proto3" json:"node_address,omitempty"`
+}
+
+func (x *GetKindsRequest) Reset() {
+	*x = GetKindsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_internal_cluster_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetKindsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetKindsRequest) ProtoMessage() {}
+
+func (x *GetKindsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_cluster_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetKindsRequest.ProtoReflect.Descriptor instead.
+func (*GetKindsRequest) Descriptor() ([]byte, []int) {
+	return file_internal_cluster_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GetKindsRequest) GetNodeAddress() string {
+	if x != nil {
+		return x.NodeAddress
+	}
+	return ""
+}
+
+type GetKindsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Specifies the list of kinds
+	Kinds []string `protobuf:"bytes,1,rep,name=kinds,proto3" json:"kinds,omitempty"`
+}
+
+func (x *GetKindsResponse) Reset() {
+	*x = GetKindsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_internal_cluster_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetKindsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetKindsResponse) ProtoMessage() {}
+
+func (x *GetKindsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_cluster_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetKindsResponse.ProtoReflect.Descriptor instead.
+func (*GetKindsResponse) Descriptor() ([]byte, []int) {
+	return file_internal_cluster_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *GetKindsResponse) GetKinds() []string {
+	if x != nil {
+		return x.Kinds
+	}
+	return nil
+}
+
+var File_internal_cluster_proto protoreflect.FileDescriptor
+
+var file_internal_cluster_proto_rawDesc = []byte{
+	0x0a, 0x16, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x70, 0x62, 0x22, 0x39, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x4d,
+	0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c,
+	0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0b, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22,
+	0x6a, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x6e, 0x6f, 0x64, 0x65,
+	0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x6e, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x74,
+	0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x74, 0x6f,
+	0x72, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b,
+	0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x34, 0x0a, 0x0f, 0x47,
+	0x65, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21,
+	0x0a, 0x0c, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
+	0x73, 0x22, 0x28, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x18, 0x01,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x32, 0xad, 0x01, 0x0a, 0x0e,
+	0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x54,
+	0x0a, 0x0d, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12,
+	0x20, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x2e, 0x47, 0x65, 0x74,
+	0x4e, 0x6f, 0x64, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x21, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x2e, 0x47,
+	0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x73,
+	0x12, 0x1b, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x2e, 0x47, 0x65,
+	0x74, 0x4b, 0x69, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x2e, 0x47, 0x65, 0x74, 0x4b, 0x69,
+	0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0xa5, 0x01, 0x0a, 0x0e,
+	0x63, 0x6f, 0x6d, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x42, 0x0c,
+	0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x48, 0x02, 0x50, 0x01,
+	0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x6f, 0x63,
+	0x68, 0x65, 0x6d, 0x65, 0x79, 0x2f, 0x67, 0x6f, 0x61, 0x6b, 0x74, 0x2f, 0x76, 0x32, 0x2f, 0x69,
+	0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
+	0x70, 0x62, 0x3b, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0xa2, 0x02, 0x03,
+	0x49, 0x58, 0x58, 0xaa, 0x02, 0x0a, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62,
+	0xca, 0x02, 0x0a, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0xe2, 0x02, 0x16,
+	0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x70, 0x62, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65,
+	0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0a, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
+	0x6c, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_internal_cluster_proto_rawDescOnce sync.Once
+	file_internal_cluster_proto_rawDescData = file_internal_cluster_proto_rawDesc
+)
+
+func file_internal_cluster_proto_rawDescGZIP() []byte {
+	file_internal_cluster_proto_rawDescOnce.Do(func() {
+		file_internal_cluster_proto_rawDescData = protoimpl.X.CompressGZIP(file_internal_cluster_proto_rawDescData)
+	})
+	return file_internal_cluster_proto_rawDescData
+}
+
+var file_internal_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_internal_cluster_proto_goTypes = []interface{}{
+	(*GetNodeMetricRequest)(nil),  // 0: internalpb.GetNodeMetricRequest
+	(*GetNodeMetricResponse)(nil), // 1: internalpb.GetNodeMetricResponse
+	(*GetKindsRequest)(nil),       // 2: internalpb.GetKindsRequest
+	(*GetKindsResponse)(nil),      // 3: internalpb.GetKindsResponse
+}
+var file_internal_cluster_proto_depIdxs = []int32{
+	0, // 0: internalpb.ClusterService.GetNodeMetric:input_type -> internalpb.GetNodeMetricRequest
+	2, // 1: internalpb.ClusterService.GetKinds:input_type -> internalpb.GetKindsRequest
+	1, // 2: internalpb.ClusterService.GetNodeMetric:output_type -> internalpb.GetNodeMetricResponse
+	3, // 3: internalpb.ClusterService.GetKinds:output_type -> internalpb.GetKindsResponse
+	2, // [2:4] is the sub-list for method output_type
+	0, // [0:2] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_internal_cluster_proto_init() }
+func file_internal_cluster_proto_init() {
+	if File_internal_cluster_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_internal_cluster_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetNodeMetricRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_internal_cluster_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetNodeMetricResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_internal_cluster_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetKindsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_internal_cluster_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetKindsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_internal_cluster_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_internal_cluster_proto_goTypes,
+		DependencyIndexes: file_internal_cluster_proto_depIdxs,
+		MessageInfos:      file_internal_cluster_proto_msgTypes,
+	}.Build()
+	File_internal_cluster_proto = out.File
+	file_internal_cluster_proto_rawDesc = nil
+	file_internal_cluster_proto_goTypes = nil
+	file_internal_cluster_proto_depIdxs = nil
+}
diff --git a/internal/internalpb/internalpbconnect/cluster.connect.go b/internal/internalpb/internalpbconnect/cluster.connect.go
new file mode 100644
index 0000000..2b1a142
--- /dev/null
+++ b/internal/internalpb/internalpbconnect/cluster.connect.go
@@ -0,0 +1,146 @@
+// Code generated by protoc-gen-connect-go. DO NOT EDIT.
+//
+// Source: internal/cluster.proto
+
+package internalpbconnect
+
+import (
+	connect "connectrpc.com/connect"
+	context "context"
+	errors "errors"
+	internalpb "github.com/tochemey/goakt/v2/internal/internalpb"
+	http "net/http"
+	strings "strings"
+)
+
+// This is a compile-time assertion to ensure that this generated file and the connect package are
+// compatible. If you get a compiler error that this constant is not defined, this code was
+// generated with a version of connect newer than the one compiled into your binary. You can fix the
+// problem by either regenerating this code with an older version of connect or updating the connect
+// version compiled into your binary.
+const _ = connect.IsAtLeastVersion1_13_0
+
+const (
+	// ClusterServiceName is the fully-qualified name of the ClusterService service.
+	ClusterServiceName = "internalpb.ClusterService"
+)
+
+// These constants are the fully-qualified names of the RPCs defined in this package. They're
+// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
+//
+// Note that these are different from the fully-qualified method names used by
+// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
+// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
+// period.
+const (
+	// ClusterServiceGetNodeMetricProcedure is the fully-qualified name of the ClusterService's
+	// GetNodeMetric RPC.
+	ClusterServiceGetNodeMetricProcedure = "/internalpb.ClusterService/GetNodeMetric"
+	// ClusterServiceGetKindsProcedure is the fully-qualified name of the ClusterService's GetKinds RPC.
+	ClusterServiceGetKindsProcedure = "/internalpb.ClusterService/GetKinds"
+)
+
+// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package.
+var (
+	clusterServiceServiceDescriptor             = internalpb.File_internal_cluster_proto.Services().ByName("ClusterService")
+	clusterServiceGetNodeMetricMethodDescriptor = clusterServiceServiceDescriptor.Methods().ByName("GetNodeMetric")
+	clusterServiceGetKindsMethodDescriptor      = clusterServiceServiceDescriptor.Methods().ByName("GetKinds")
+)
+
+// ClusterServiceClient is a client for the internalpb.ClusterService service.
+type ClusterServiceClient interface {
+	// GetNodeMetric returns the node metric
+	GetNodeMetric(context.Context, *connect.Request[internalpb.GetNodeMetricRequest]) (*connect.Response[internalpb.GetNodeMetricResponse], error)
+	// GetKinds returns the list of cluster kinds
+	GetKinds(context.Context, *connect.Request[internalpb.GetKindsRequest]) (*connect.Response[internalpb.GetKindsResponse], error)
+}
+
+// NewClusterServiceClient constructs a client for the internalpb.ClusterService service. By
+// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
+// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
+// connect.WithGRPC() or connect.WithGRPCWeb() options.
+//
+// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
+// http://api.acme.com or https://acme.com/grpc).
+func NewClusterServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ClusterServiceClient {
+	baseURL = strings.TrimRight(baseURL, "/")
+	return &clusterServiceClient{
+		getNodeMetric: connect.NewClient[internalpb.GetNodeMetricRequest, internalpb.GetNodeMetricResponse](
+			httpClient,
+			baseURL+ClusterServiceGetNodeMetricProcedure,
+			connect.WithSchema(clusterServiceGetNodeMetricMethodDescriptor),
+			connect.WithClientOptions(opts...),
+		),
+		getKinds: connect.NewClient[internalpb.GetKindsRequest, internalpb.GetKindsResponse](
+			httpClient,
+			baseURL+ClusterServiceGetKindsProcedure,
+			connect.WithSchema(clusterServiceGetKindsMethodDescriptor),
+			connect.WithClientOptions(opts...),
+		),
+	}
+}
+
+// clusterServiceClient implements ClusterServiceClient.
+type clusterServiceClient struct {
+	getNodeMetric *connect.Client[internalpb.GetNodeMetricRequest, internalpb.GetNodeMetricResponse]
+	getKinds      *connect.Client[internalpb.GetKindsRequest, internalpb.GetKindsResponse]
+}
+
+// GetNodeMetric calls internalpb.ClusterService.GetNodeMetric.
+func (c *clusterServiceClient) GetNodeMetric(ctx context.Context, req *connect.Request[internalpb.GetNodeMetricRequest]) (*connect.Response[internalpb.GetNodeMetricResponse], error) {
+	return c.getNodeMetric.CallUnary(ctx, req)
+}
+
+// GetKinds calls internalpb.ClusterService.GetKinds.
+func (c *clusterServiceClient) GetKinds(ctx context.Context, req *connect.Request[internalpb.GetKindsRequest]) (*connect.Response[internalpb.GetKindsResponse], error) {
+	return c.getKinds.CallUnary(ctx, req)
+}
+
+// ClusterServiceHandler is an implementation of the internalpb.ClusterService service.
+type ClusterServiceHandler interface {
+	// GetNodeMetric returns the node metric
+	GetNodeMetric(context.Context, *connect.Request[internalpb.GetNodeMetricRequest]) (*connect.Response[internalpb.GetNodeMetricResponse], error)
+	// GetKinds returns the list of cluster kinds
+	GetKinds(context.Context, *connect.Request[internalpb.GetKindsRequest]) (*connect.Response[internalpb.GetKindsResponse], error)
+}
+
+// NewClusterServiceHandler builds an HTTP handler from the service implementation. It returns the
+// path on which to mount the handler and the handler itself.
+//
+// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
+// and JSON codecs. They also support gzip compression.
+func NewClusterServiceHandler(svc ClusterServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
+	clusterServiceGetNodeMetricHandler := connect.NewUnaryHandler(
+		ClusterServiceGetNodeMetricProcedure,
+		svc.GetNodeMetric,
+		connect.WithSchema(clusterServiceGetNodeMetricMethodDescriptor),
+		connect.WithHandlerOptions(opts...),
+	)
+	clusterServiceGetKindsHandler := connect.NewUnaryHandler(
+		ClusterServiceGetKindsProcedure,
+		svc.GetKinds,
+		connect.WithSchema(clusterServiceGetKindsMethodDescriptor),
+		connect.WithHandlerOptions(opts...),
+	)
+	return "/internalpb.ClusterService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case ClusterServiceGetNodeMetricProcedure:
+			clusterServiceGetNodeMetricHandler.ServeHTTP(w, r)
+		case ClusterServiceGetKindsProcedure:
+			clusterServiceGetKindsHandler.ServeHTTP(w, r)
+		default:
+			http.NotFound(w, r)
+		}
+	})
+}
+
+// UnimplementedClusterServiceHandler returns CodeUnimplemented from all methods.
+type UnimplementedClusterServiceHandler struct{}
+
+func (UnimplementedClusterServiceHandler) GetNodeMetric(context.Context, *connect.Request[internalpb.GetNodeMetricRequest]) (*connect.Response[internalpb.GetNodeMetricResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("internalpb.ClusterService.GetNodeMetric is not implemented"))
+}
+
+func (UnimplementedClusterServiceHandler) GetKinds(context.Context, *connect.Request[internalpb.GetKindsRequest]) (*connect.Response[internalpb.GetKindsResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("internalpb.ClusterService.GetKinds is not implemented"))
+}
diff --git a/internal/validation/tcp_address.go b/internal/validation/tcp_address.go
new file mode 100644
index 0000000..a05a572
--- /dev/null
+++ b/internal/validation/tcp_address.go
@@ -0,0 +1,70 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package validation
+
+import (
+	"fmt"
+	"net"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+var errFmt = "invalid address=(%s)"
+
+// TCPAddressValidator helps validate a TCP address
+type TCPAddressValidator struct {
+	address string
+}
+
+// making sure the given struct implements the given interface
+var _ Validator = (*TCPAddressValidator)(nil)
+
+// NewTCPAddressValidator creates an instance of TCPAddressValidator
+func NewTCPAddressValidator(address string) *TCPAddressValidator {
+	return &TCPAddressValidator{address: address}
+}
+
+// Validate implements validation.Validator.
+func (a *TCPAddressValidator) Validate() error {
+	host, port, err := net.SplitHostPort(strings.TrimSpace(a.address))
+	if err != nil {
+		return errors.Wrapf(err, errFmt, a.address)
+	}
+
+	// let us validate the port number
+	portNum, err := strconv.Atoi(port)
+	if err != nil {
+		return errors.Wrapf(err, errFmt, a.address)
+	}
+
+	// TODO: maybe we only need to check port number not to be negative
+	if host == "" || portNum > 65535 || portNum < 1 {
+		return fmt.Errorf(errFmt, a.address)
+	}
+
+	return nil
+}
diff --git a/mocks/cluster/interface.go b/mocks/cluster/interface.go
index b597eed..5de05e4 100644
--- a/mocks/cluster/interface.go
+++ b/mocks/cluster/interface.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.43.2. DO NOT EDIT.
+// Code generated by mockery. DO NOT EDIT.
 
 package cluster
 
diff --git a/mocks/discovery/provider.go b/mocks/discovery/provider.go
index 0412c0e..1bfc068 100644
--- a/mocks/discovery/provider.go
+++ b/mocks/discovery/provider.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.43.2. DO NOT EDIT.
+// Code generated by mockery. DO NOT EDIT.
 
 package discovery
 
diff --git a/mocks/hash/hasher.go b/mocks/hash/hasher.go
index ddf3f60..86228d0 100644
--- a/mocks/hash/hasher.go
+++ b/mocks/hash/hasher.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.43.2. DO NOT EDIT.
+// Code generated by mockery. DO NOT EDIT.
 
 package hash
 
diff --git a/protos/internal/cluster.proto b/protos/internal/cluster.proto
new file mode 100644
index 0000000..093ce43
--- /dev/null
+++ b/protos/internal/cluster.proto
@@ -0,0 +1,35 @@
+syntax = "proto3";
+
+package internalpb;
+
+option go_package = "github.com/tochemey/goakt/v2/internal/internalpb;internalpb";
+
+// ClusterService is used to interact with the cluster internal
+service ClusterService {
+  // GetNodeMetric returns the node metric
+  rpc GetNodeMetric(GetNodeMetricRequest) returns (GetNodeMetricResponse);
+  // GetKinds returns the list of cluster kinds
+  rpc GetKinds(GetKindsRequest) returns (GetKindsResponse);
+}
+
+message GetNodeMetricRequest {
+  // Specifies the node address
+  string node_address = 1;
+}
+
+message GetNodeMetricResponse {
+  // Specifies the node address
+  string node_remote_address = 1;
+  // Specifies the actors count for the given node
+  uint64 actors_count = 2;
+}
+
+message GetKindsRequest {
+  // Specifies the node address
+  string node_address = 1;
+}
+
+message GetKindsResponse {
+  // Specifies the list of kinds
+  repeated string kinds = 1;
+}


Test patch:
diff --git a/actors/actor_system_test.go b/actors/actor_system_test.go
index 3fef36b..f9bd057 100644
--- a/actors/actor_system_test.go
+++ b/actors/actor_system_test.go
@@ -51,7 +51,7 @@ import (
 	clustermocks "github.com/tochemey/goakt/v2/mocks/cluster"
 	testkit "github.com/tochemey/goakt/v2/mocks/discovery"
 	"github.com/tochemey/goakt/v2/telemetry"
-	testpb "github.com/tochemey/goakt/v2/test/data/testpb"
+	"github.com/tochemey/goakt/v2/test/data/testpb"
 )
 
 // nolint
@@ -1382,7 +1382,6 @@ func TestActorSystem(t *testing.T) {
 			assert.Error(t, err)
 		})
 	})
-
 	t.Run("With cluster start failure with remoting not enabled", func(t *testing.T) {
 		ctx := context.TODO()
 		logger := log.DiscardLogger
diff --git a/actors/api_test.go b/actors/api_test.go
index 3ec3c91..93f1664 100644
--- a/actors/api_test.go
+++ b/actors/api_test.go
@@ -38,9 +38,9 @@ import (
 	"google.golang.org/protobuf/types/known/anypb"
 
 	"github.com/tochemey/goakt/v2/goaktpb"
-	internalpb "github.com/tochemey/goakt/v2/internal/internalpb"
+	"github.com/tochemey/goakt/v2/internal/internalpb"
 	"github.com/tochemey/goakt/v2/log"
-	testpb "github.com/tochemey/goakt/v2/test/data/testpb"
+	"github.com/tochemey/goakt/v2/test/data/testpb"
 )
 
 func TestAsk(t *testing.T) {
@@ -1778,7 +1778,7 @@ func TestAPIRemoteSpawn(t *testing.T) {
 		// generate the remoting port
 		ports := dynaport.Get(1)
 		remotingPort := ports[0]
-		host := "localhost"
+		host := "127.0.0.1"
 
 		// create the actor system
 		sys, err := NewActorSystem("test",
diff --git a/actors/context_test.go b/actors/context_test.go
index 920f4be..f92a827 100644
--- a/actors/context_test.go
+++ b/actors/context_test.go
@@ -40,7 +40,7 @@ import (
 	"github.com/tochemey/goakt/v2/goaktpb"
 	"github.com/tochemey/goakt/v2/internal/eventstream"
 	"github.com/tochemey/goakt/v2/log"
-	testpb "github.com/tochemey/goakt/v2/test/data/testpb"
+	"github.com/tochemey/goakt/v2/test/data/testpb"
 	testspb "github.com/tochemey/goakt/v2/test/data/testpb"
 )
 
diff --git a/actors/fn_test.go b/actors/func_actor_test.go
similarity index 92%
rename from actors/fn_test.go
rename to actors/func_actor_test.go
index 7b07cc7..8b832d3 100644
--- a/actors/fn_test.go
+++ b/actors/func_actor_test.go
@@ -36,23 +36,23 @@ func TestFuncOption(t *testing.T) {
 	testCases := []struct {
 		name     string
 		option   FuncOption
-		expected fnActor
+		expected funcActor
 	}{
 
 		{
 			name:     "WithPreStart",
 			option:   WithPreStart(preStart),
-			expected: fnActor{preStart: preStart},
+			expected: funcActor{preStart: preStart},
 		},
 		{
 			name:     "WithPostStop",
 			option:   WithPostStop(postStop),
-			expected: fnActor{postStop: postStop},
+			expected: funcActor{postStop: postStop},
 		},
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			var cfg fnActor
+			var cfg funcActor
 			tc.option.Apply(&cfg)
 			assert.Equal(t, tc.expected, cfg)
 		})
diff --git a/actors/option_test.go b/actors/option_test.go
index d7bdf16..681732b 100644
--- a/actors/option_test.go
+++ b/actors/option_test.go
@@ -133,6 +133,11 @@ func TestOption(t *testing.T) {
 			option:   WithCluster(clusterConfig),
 			expected: actorSystem{clusterEnabled: atomicTrue, clusterConfig: clusterConfig},
 		},
+		{
+			name:     "WithPeerStateLoopInterval",
+			option:   WithPeerStateLoopInterval(2 * time.Second),
+			expected: actorSystem{peersStateLoopInterval: 2. * time.Second},
+		},
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
diff --git a/client/client_test.go b/client/client_test.go
new file mode 100644
index 0000000..a930a7c
--- /dev/null
+++ b/client/client_test.go
@@ -0,0 +1,494 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	natsserver "github.com/nats-io/nats-server/v2/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/travisjeffery/go-dynaport"
+	"go.uber.org/goleak"
+	"google.golang.org/protobuf/proto"
+
+	"github.com/tochemey/goakt/v2/actors"
+	"github.com/tochemey/goakt/v2/discovery"
+	"github.com/tochemey/goakt/v2/discovery/nats"
+	"github.com/tochemey/goakt/v2/goaktpb"
+	"github.com/tochemey/goakt/v2/log"
+	"github.com/tochemey/goakt/v2/test/data/testpb"
+	testspb "github.com/tochemey/goakt/v2/test/data/testpb"
+)
+
+func TestMain(m *testing.M) {
+	goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("github.com/golang/glog.(*loggingT).flushDaemon"),
+		goleak.IgnoreTopFunction("github.com/go-redis/redis/v8/internal/pool.(*ConnPool).reaper"),
+		goleak.IgnoreTopFunction("golang.org/x/net/http2.(*serverConn).serve"),
+		goleak.IgnoreTopFunction("github.com/nats-io/nats%2ego.(*Conn).doReconnect"),
+		goleak.IgnoreTopFunction("sync.runtime_notifyListWait"),
+		goleak.IgnoreTopFunction("internal/poll.runtime_pollWait"))
+}
+
+func TestClient(t *testing.T) {
+	t.Run("With defaults", func(t *testing.T) {
+		ctx := context.TODO()
+
+		logger := log.DiscardLogger
+
+		// start the NATS server
+		srv := startNatsServer(t)
+		addr := srv.Addr().String()
+
+		sys1, node1Host, node1Port, sd1 := startNode(t, logger, "node1", addr)
+		sys2, node2Host, node2Port, sd2 := startNode(t, logger, "node2", addr)
+		sys3, node3Host, node3Port, sd3 := startNode(t, logger, "node3", addr)
+
+		// wait for a proper and clean setup of the cluster
+		time.Sleep(time.Second)
+
+		addresses := []string{
+			fmt.Sprintf("%s:%d", node1Host, node1Port),
+			fmt.Sprintf("%s:%d", node2Host, node2Port),
+			fmt.Sprintf("%s:%d", node3Host, node3Port),
+		}
+
+		client, err := New(ctx, addresses)
+		require.NoError(t, err)
+		require.NotNil(t, client)
+
+		kinds, err := client.Kinds(ctx)
+		require.NoError(t, err)
+		require.NotNil(t, kinds)
+		require.NotEmpty(t, kinds)
+		require.Len(t, kinds, 2)
+
+		expected := []string{
+			"actors.funcactor",
+			"client.testactor",
+		}
+
+		require.ElementsMatch(t, expected, kinds)
+		actor := NewActor("client.testactor").WithName("actorName")
+
+		err = client.Spawn(ctx, actor)
+		require.NoError(t, err)
+
+		time.Sleep(time.Second)
+
+		// send a message
+		reply, err := client.Ask(ctx, actor, new(testspb.TestReply))
+		require.NoError(t, err)
+		require.NotNil(t, reply)
+		expectedReply := &testpb.Reply{Content: "received message"}
+		assert.True(t, proto.Equal(expectedReply, reply))
+
+		time.Sleep(time.Second)
+
+		err = client.Tell(ctx, actor, new(testspb.TestSend))
+		require.NoError(t, err)
+
+		err = client.Kill(ctx, actor)
+		require.NoError(t, err)
+
+		t.Cleanup(func() {
+			client.Close()
+
+			require.NoError(t, sys1.Stop(ctx))
+			require.NoError(t, sys2.Stop(ctx))
+			require.NoError(t, sys3.Stop(ctx))
+
+			require.NoError(t, sd1.Close())
+			require.NoError(t, sd2.Close())
+			require.NoError(t, sd3.Close())
+
+			srv.Shutdown()
+			time.Sleep(time.Second)
+		})
+	})
+	t.Run("With Random strategy", func(t *testing.T) {
+		ctx := context.TODO()
+		logger := log.DiscardLogger
+		// start the NATS server
+		srv := startNatsServer(t)
+		addr := srv.Addr().String()
+
+		sys1, node1Host, node1Port, sd1 := startNode(t, logger, "node1", addr)
+		sys2, node2Host, node2Port, sd2 := startNode(t, logger, "node2", addr)
+		sys3, node3Host, node3Port, sd3 := startNode(t, logger, "node3", addr)
+
+		// wait for a proper and clean setup of the cluster
+		time.Sleep(time.Second)
+
+		addresses := []string{
+			fmt.Sprintf("%s:%d", node1Host, node1Port),
+			fmt.Sprintf("%s:%d", node2Host, node2Port),
+			fmt.Sprintf("%s:%d", node3Host, node3Port),
+		}
+
+		client, err := New(ctx,
+			addresses,
+			WithBalancerStrategy(RoundRobinStrategy))
+
+		require.NoError(t, err)
+		require.NotNil(t, client)
+
+		kinds, err := client.Kinds(ctx)
+		require.NoError(t, err)
+		require.NotNil(t, kinds)
+		require.NotEmpty(t, kinds)
+		require.Len(t, kinds, 2)
+
+		expected := []string{
+			"actors.funcactor",
+			"client.testactor",
+		}
+
+		require.ElementsMatch(t, expected, kinds)
+		actor := NewActor("client.testactor").WithName("actorName")
+
+		err = client.Spawn(ctx, actor)
+		require.NoError(t, err)
+
+		time.Sleep(time.Second)
+
+		// send a message
+		reply, err := client.Ask(ctx, actor, new(testspb.TestReply))
+		require.NoError(t, err)
+		require.NotNil(t, reply)
+		expectedReply := &testpb.Reply{Content: "received message"}
+		assert.True(t, proto.Equal(expectedReply, reply))
+
+		time.Sleep(time.Second)
+
+		err = client.Tell(ctx, actor, new(testspb.TestSend))
+		require.NoError(t, err)
+
+		err = client.Kill(ctx, actor)
+		require.NoError(t, err)
+
+		t.Cleanup(func() {
+			client.Close()
+
+			require.NoError(t, sys1.Stop(ctx))
+			require.NoError(t, sys2.Stop(ctx))
+			require.NoError(t, sys3.Stop(ctx))
+
+			require.NoError(t, sd1.Close())
+			require.NoError(t, sd2.Close())
+			require.NoError(t, sd3.Close())
+
+			srv.Shutdown()
+
+			time.Sleep(time.Second)
+		})
+	})
+	t.Run("With Least-Load strategy", func(t *testing.T) {
+		ctx := context.TODO()
+		logger := log.DiscardLogger
+		// start the NATS server
+		srv := startNatsServer(t)
+		addr := srv.Addr().String()
+
+		sys1, node1Host, node1Port, sd1 := startNode(t, logger, "node1", addr)
+		sys2, node2Host, node2Port, sd2 := startNode(t, logger, "node2", addr)
+		sys3, node3Host, node3Port, sd3 := startNode(t, logger, "node3", addr)
+
+		// wait for a proper and clean setup of the cluster
+		time.Sleep(time.Second)
+
+		addresses := []string{
+			fmt.Sprintf("%s:%d", node1Host, node1Port),
+			fmt.Sprintf("%s:%d", node2Host, node2Port),
+			fmt.Sprintf("%s:%d", node3Host, node3Port),
+		}
+
+		client, err := New(ctx,
+			addresses,
+			WithBalancerStrategy(LeastLoadStrategy))
+
+		require.NoError(t, err)
+		require.NotNil(t, client)
+
+		kinds, err := client.Kinds(ctx)
+		require.NoError(t, err)
+		require.NotNil(t, kinds)
+		require.NotEmpty(t, kinds)
+		require.Len(t, kinds, 2)
+
+		expected := []string{
+			"actors.funcactor",
+			"client.testactor",
+		}
+
+		require.ElementsMatch(t, expected, kinds)
+		actor := NewActor("client.testactor").WithName("actorName")
+
+		err = client.Spawn(ctx, actor)
+		require.NoError(t, err)
+
+		time.Sleep(time.Second)
+
+		// send a message
+		reply, err := client.Ask(ctx, actor, new(testspb.TestReply))
+		require.NoError(t, err)
+		require.NotNil(t, reply)
+		expectedReply := &testpb.Reply{Content: "received message"}
+		assert.True(t, proto.Equal(expectedReply, reply))
+
+		time.Sleep(time.Second)
+
+		err = client.Tell(ctx, actor, new(testspb.TestSend))
+		require.NoError(t, err)
+
+		err = client.Kill(ctx, actor)
+		require.NoError(t, err)
+
+		t.Cleanup(func() {
+			client.Close()
+
+			require.NoError(t, sys1.Stop(ctx))
+			require.NoError(t, sys2.Stop(ctx))
+			require.NoError(t, sys3.Stop(ctx))
+
+			require.NoError(t, sd1.Close())
+			require.NoError(t, sd2.Close())
+			require.NoError(t, sd3.Close())
+
+			srv.Shutdown()
+			time.Sleep(time.Second)
+		})
+	})
+	t.Run("With Refresh Interval", func(t *testing.T) {
+		ctx := context.TODO()
+
+		logger := log.DiscardLogger
+
+		// start the NATS server
+		srv := startNatsServer(t)
+		addr := srv.Addr().String()
+
+		sys1, node1Host, node1Port, sd1 := startNode(t, logger, "node1", addr)
+		sys2, node2Host, node2Port, sd2 := startNode(t, logger, "node2", addr)
+		sys3, node3Host, node3Port, sd3 := startNode(t, logger, "node3", addr)
+
+		// wait for a proper and clean setup of the cluster
+		time.Sleep(time.Second)
+
+		addresses := []string{
+			fmt.Sprintf("%s:%d", node1Host, node1Port),
+			fmt.Sprintf("%s:%d", node2Host, node2Port),
+			fmt.Sprintf("%s:%d", node3Host, node3Port),
+		}
+
+		client, err := New(ctx, addresses, WithRefresh(time.Minute))
+		require.NoError(t, err)
+		require.NotNil(t, client)
+
+		kinds, err := client.Kinds(ctx)
+		require.NoError(t, err)
+		require.NotNil(t, kinds)
+		require.NotEmpty(t, kinds)
+		require.Len(t, kinds, 2)
+
+		expected := []string{
+			"actors.funcactor",
+			"client.testactor",
+		}
+
+		require.ElementsMatch(t, expected, kinds)
+		actor := NewActor("client.testactor").WithName("actorName")
+
+		err = client.Spawn(ctx, actor)
+		require.NoError(t, err)
+
+		time.Sleep(time.Second)
+
+		// send a message
+		reply, err := client.Ask(ctx, actor, new(testspb.TestReply))
+		require.NoError(t, err)
+		require.NotNil(t, reply)
+		expectedReply := &testpb.Reply{Content: "received message"}
+		assert.True(t, proto.Equal(expectedReply, reply))
+
+		time.Sleep(time.Second)
+
+		err = client.Tell(ctx, actor, new(testspb.TestSend))
+		require.NoError(t, err)
+
+		err = client.Kill(ctx, actor)
+		require.NoError(t, err)
+
+		t.Cleanup(func() {
+			client.Close()
+
+			require.NoError(t, sys1.Stop(ctx))
+			require.NoError(t, sys2.Stop(ctx))
+			require.NoError(t, sys3.Stop(ctx))
+
+			require.NoError(t, sd1.Close())
+			require.NoError(t, sd2.Close())
+			require.NoError(t, sd3.Close())
+
+			srv.Shutdown()
+
+			time.Sleep(time.Second)
+		})
+	})
+}
+
+func startNatsServer(t *testing.T) *natsserver.Server {
+	t.Helper()
+	serv, err := natsserver.NewServer(&natsserver.Options{
+		Host: "127.0.0.1",
+		Port: -1,
+	})
+
+	require.NoError(t, err)
+
+	ready := make(chan bool)
+	go func() {
+		ready <- true
+		serv.Start()
+	}()
+	<-ready
+
+	if !serv.ReadyForConnections(2 * time.Second) {
+		t.Fatalf("nats-io server failed to start")
+	}
+
+	return serv
+}
+
+func startNode(t *testing.T, logger log.Logger, nodeName, serverAddr string) (system actors.ActorSystem, remotingHost string, remotingPort int, provider discovery.Provider) {
+	ctx := context.TODO()
+
+	// generate the ports for the single startNode
+	nodePorts := dynaport.Get(3)
+	gossipPort := nodePorts[0]
+	peersPort := nodePorts[1]
+	remotePort := nodePorts[2]
+
+	// create a Cluster startNode
+	host := "127.0.0.1"
+	// create the various config option
+	applicationName := "accounts"
+	actorSystemName := "testSystem"
+	natsSubject := "some-subject"
+	// create the config
+	config := nats.Config{
+		ApplicationName: applicationName,
+		ActorSystemName: actorSystemName,
+		NatsServer:      serverAddr,
+		NatsSubject:     natsSubject,
+	}
+
+	hostNode := discovery.Node{
+		Name:         nodeName,
+		Host:         host,
+		GossipPort:   gossipPort,
+		PeersPort:    peersPort,
+		RemotingPort: remotePort,
+	}
+
+	// create the instance of provider
+	natsProvider := nats.NewDiscovery(&config, &hostNode, nats.WithLogger(logger))
+
+	clusterConfig := actors.
+		NewClusterConfig().
+		WithKinds(new(testActor)).
+		WithDiscovery(natsProvider).
+		WithPeersPort(peersPort).
+		WithGossipPort(gossipPort).
+		WithReplicaCount(1).
+		WithMinimumPeersQuorum(1).
+		WithPartitionCount(10)
+
+	// create the actor system
+	system, err := actors.NewActorSystem(
+		applicationName,
+		actors.WithPassivationDisabled(),
+		actors.WithLogger(logger),
+		actors.WithReplyTimeout(time.Minute),
+		actors.WithRemoting(host, int32(remotePort)),
+		actors.WithPeerStateLoopInterval(100*time.Millisecond),
+		actors.WithCluster(clusterConfig))
+
+	require.NotNil(t, system)
+	require.NoError(t, err)
+
+	// start the node
+	require.NoError(t, system.Start(ctx))
+
+	time.Sleep(time.Second)
+
+	logger.Infof("node information=%s", hostNode.String())
+
+	// return the cluster startNode
+	return system, host, remotePort, natsProvider
+}
+
+type testActor struct {
+	logger log.Logger
+}
+
+// enforce compilation error
+var _ actors.Actor = (*testActor)(nil)
+
+// newTestActor creates a testActor
+func newTestActor() *testActor {
+	return &testActor{}
+}
+
+// Init initialize the actor. This function can be used to set up some database connections
+// or some sort of initialization before the actor init processing public
+func (p *testActor) PreStart(context.Context) error {
+	p.logger = log.DefaultLogger
+	p.logger.Info("pre start")
+	return nil
+}
+
+// Shutdown gracefully shuts down the given actor
+func (p *testActor) PostStop(context.Context) error {
+	p.logger.Info("post stop")
+	return nil
+}
+
+// Receive processes any message dropped into the actor mailbox without a reply
+func (p *testActor) Receive(ctx actors.ReceiveContext) {
+	switch ctx.Message().(type) {
+	case *goaktpb.PostStart:
+		p.logger.Info("post start")
+	case *testspb.TestSend:
+	case *testspb.TestReply:
+		p.logger.Info("received a test reply message...")
+		ctx.Response(&testspb.Reply{Content: "received message"})
+	default:
+		ctx.Unhandled()
+	}
+}
diff --git a/client/least_load_test.go b/client/least_load_test.go
new file mode 100644
index 0000000..14854cb
--- /dev/null
+++ b/client/least_load_test.go
@@ -0,0 +1,40 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLeadLoad(t *testing.T) {
+	balancer := NewLeastLoad()
+	balancer.Set(NewNode("192.168.34.10:3322", 2),
+		NewNode("192.168.34.11:3322", 0),
+		NewNode("192.168.34.12:3322", 1))
+	actual := balancer.Next()
+	assert.Equal(t, "192.168.34.11:3322", actual.Address())
+}
diff --git a/client/option_test.go b/client/option_test.go
new file mode 100644
index 0000000..b52eac6
--- /dev/null
+++ b/client/option_test.go
@@ -0,0 +1,58 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOption(t *testing.T) {
+	testCases := []struct {
+		name     string
+		option   Option
+		expected Client
+	}{
+		{
+			name:     "WithBalancerStrategy",
+			option:   WithBalancerStrategy(LeastLoadStrategy),
+			expected: Client{strategy: LeastLoadStrategy},
+		},
+		{
+			name:     "WithRefresh",
+			option:   WithRefresh(time.Second),
+			expected: Client{refreshInterval: time.Second},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			var cfg Client
+			tc.option.Apply(&cfg)
+			assert.Equal(t, tc.expected, cfg)
+		})
+	}
+}
diff --git a/client/round_robin_test.go b/client/round_robin_test.go
new file mode 100644
index 0000000..ff99d48
--- /dev/null
+++ b/client/round_robin_test.go
@@ -0,0 +1,52 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2022-2024 Tochemey
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package client
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRoundRobin(t *testing.T) {
+	expected := []string{
+		"192.168.34.10:3322",
+		"192.168.34.11:3322",
+		"192.168.34.12:3322",
+		"192.168.34.10:3322",
+	}
+
+	balancer := NewRoundRobin()
+	balancer.Set(NewNode("192.168.34.10:3322", 2),
+		NewNode("192.168.34.11:3322", 0),
+		NewNode("192.168.34.12:3322", 1))
+
+	actual := make([]string, 4)
+	for i := 0; i < 4; i++ {
+		actual[i] = balancer.Next().Address()
+	}
+
+	assert.ElementsMatch(t, expected, actual)
+}