1

I'm building a service to send push notifications to the user.

At first, I designed an Interface for the push notification adapters, something like this:

interface PushNotificationAdapterInterface
{
    /**
     * @throws NotificationException
     */
    function sendNotification(string $appIdentifier, PushNotificationTemplate $pushNotificationTemplate): void;
}

class PushNotificationTemplate
{
    public function __construct(
        private string $to,
        private string $from,
        private string $title,
        private string $body
    )
    {
    }
}

I tried to model the interface based on the basic needs required to send a push notification, which usually is: "to" (where to send), "from" (who is sending), "title", and "body".

the problem:

Not all the concrete adapters would use the PushNotificationTemplate properties to send the notification and in theory, I know that this should not be a problem/concern to the interface, but it should for those who implement it.

Some adapters are too different to conform to this interface, an example is one that uses an API to send the notification, using only specific params of the API and not using a single property of PushNotificationTemplate

API example:

class ApiPushNotificationAdapter implements PushNotificationAdapterInterface {

    public function __construct(
       private string $customApiParam1,
       private string $customApiParam2,
      //... more params
    )
    {

    }
    function sendNotification(string $appIdentifier, PushNotificationTemplate $pushNotificationTemplate): void
    {
        // not using $pushNotificationTemplate
    }
}

one problem with this specific adapter:

some constructor parameters should be used in the sendNotification() method because they're are dynamic and should be changed with ease, such as title and body (the API uses a different approach, with a template_id being a constructor parameter)

Firebase implementation example that fits the Interface:

class FirebasePushNotificationAdapter implements PushNotificationAdapterInterface {

    public function __construct(
       private string $googleApiKey,
        private string $googleSDK,
      //... more params
    )
    {

    }
    function sendNotification(string $appIdentifier, PushNotificationTemplate $pushNotificationTemplate): void
    {
       $this->googleSDK->send(
           $pushNotificationTemplate->to(),
           $pushNotificationTemplate->from(),
           $pushNotificationTemplate->title(),
           //....
       )
    }
}

I thought about using an array as the second parameter, but I don't like it at all, being an array takes out the standardization and wouldn't be useful or meaningful for all the adapters.

I know that these are low-level implementation details that could be passed into the constructor, but in the end, not all adapters would use the PushNotificationTemplate properties or even the appIdentifier variable

What are some good approaches/patterns to solve this scenario? create some kind of abstract factory for different adapters? does it even fit as an adapter?

Thiago Dias
  • 383
  • 3
  • 11
  • 1
    You're thinking about the interface as if it were just a function, but remember, it's *objects* that'll implement that interface; they can all store their implementation-specific state for later use. All the different parameters don't have to appear in the interface; try to find a reasonable generalized parameter list for the `sendNotification` method (or perhaps even leave it empty), and let object constructors handle everything else. At construction time, client code can pick a concrete class, and give it the specific parameters it needs, than pass the object along to be treated abstractly. – Filip Milovanović Jun 26 '21 at 19:21

1 Answers1

1

It seems you have a few design targets:

  1. Use a Service Interface to decouple the service consumer and service provider.
  2. Make the service itself Open For Extending so if there is a new service provider the consumer side won't have to make any code change or very minimum change to use the new service implementation.

Based on these targets and your requirement that Service Providers could be quite different on how they handle Push Notifications, I think your choice of Adapter pattern is not the right fit for this use case. Instead, I think you should consider these design patterns: Service Provider, Vistor/Strategy. Let's go through your case to see how to use those patterns.

I don't know what language you are using to solve the problem. I am a Java lover so I will demo the design in java.

  1. First of all, you want great flexibility on the template but still want the service providers stick to the same contract defined by the interface. For this use case, I would create a Message interface to generify different template types. (Message interface maybe an empty interface, or you can add common functions shared by most template types).

    public interface Message { }
    
  2. Then we will declare a Service Provider interface which use Message generic type to give its implementations flexibility on define their own Message (template) types without break the contract defined by the interface.

    public interface PushNotificationServiceProvider<T extends Message> {
        boolean accept(Message message);
        void send(T message);
    }
    

    Here I delcared two methods:

    • A generic send method which make it versatile to handle different Message types.
    • A accept method which we will use later to implement the Vistor pattern which can help us auto dispatch a specific message to the right service provider for sending.
  3. Now we have a generified framework to add concrete service provider implementations. We will create two service providers and their specific message types:

    • APIPushNotificationServiceProvider:
    // Just a dumb message without any information
    public class APIMessage implements Message {
        public APIMessage(){}
    }
    
    public class APIPushNotificationServiceProvider implements 
            PushNotificationServiceProvider<APIMessage> {
    
        private final String apiParam1;
        private final String apiParam2;
    
        public ApiPushNotificationProvider(String apiParam1, String apiParam2) {
            this.apiParam1 = apiParam1;
            this.apiParam2 = apiParam2;
        }
    
        private void auth(Function<> onSuccess) {
            // Authenticate
            if(isAuthenticated) {
                onSuccess.apply()
            }
            throw new UnauthorizedException();
        }
    
        public boolean accept(Message message) {
            return message != null && message instanceof APIMessage;
        }
    
        public void send(final APIMessage payload) {
            // Send payload with API parameters  
            auth(() -> {
                // Implementation Details 
            });
        }
    }
    
    
    • FirebasePushNotificationServiceProvider
    public class FirebaseMessage implements Message {
        private final String to;
        private final String from;
        private final String title;
        private final String body;
    
        public FirebaseMessage(String to, String from, String title, String body) {
            this.to = to;
            this.from = from;
            this.title = title;
            this.body = body;
       }
    }
    
    public class FirebasePushNotificationServiceProvider implements PushNotificationServiceProvider<FirebaseMessage> {
        private final String googleApiKey;
        private final GoogleSDK googleSDK;
    
        public FirebasePushNotificationService(String googleApiKey) {
            this.googleApiKey = googleApiKey;
            // I have no idea how GoogleSDK works, assume you need to initialize it before use.
            this.googleSDK = GoogleSDK.initWith(googleApiKey);
        }
    
        public boolean accept(Message message) {
            return message != null && message instanceof FirebaseMessage;
        }
    
        public void send(FirebaseMessage payload) {
    
            // send with Google SDK  
            this.googleSDK.send(payload.getTo(), payload.getFrom(), payload.getTitle(), payload.getBody());
        }
    }
    
    
    
  4. Now we have all the providers. As the module designer you need to expose your Push Notification module to service consumers (usually other developers who working in high level application domain). We should hide the implementation details of our module and provide them a clean way to use the servcie. We will create a singleton Service class as the module entry and use the Vistor pattern internally to dispatch different types of message to the right provider.

    public class PushNotificationService {
    
        private Collection<PushNotificationServiceProvider> providers = new ArrayList();
    
        private PushNotificationService _instance;    
    
        PushNotificationService() {
            // Initialize and register service providers, there are two options:
    
            // Manual regsiter
            if(config.APIPushNotificationEnabled) {
                APIPushNotificationServiceProvider apiPushNotificationProvider = 
                    new ApiPushNotificationProvider(config.APIPushNotification.apiParam1, config.APIPushNotification.apiParam2);
    
                providers.add(apiPushNotificationProvider);
            }
            if(config.FirebasePushNotificationEnabled) {
                FirebasePushNotificationServiceProvider firebasePushNotificationProvider 
                    = new FirebasePushNotificationServiceProvider(config.FirebasePushNotification.googleApiKey);
                providers.add(firebasePushNotificationProvider);
            }
    
            // You can register more providers in the future
    
        }
    
        public PushNotificationService getInstance() {
            if(_instance == null) _instance = new PushNotificationService();
            return _instance;
        }
    
    
        public void send(Message message) {
            for(PushNotificationServiceProvider provider : providers) {
                if(provider.accept(message)) {
                    provider.send(message);
                }
            }
        }
    }
    
    
    • The PushNotificationService is a Sigleton because we only need one instance at runtime.
    • We registered all available service providers into the Push Notification service module.
    • The send(Message message) method encapsulate the concrete sending dispatch implementation. Service users only need to face one standard method.
  5. Finally, in the client space where Push Notification services will be consumed, the usage is simple and clean and with Type Safety:

    // Client side - Send multiple messages 
    PushNotificationService pushNotificationService = PushNotificationService.getInstance();
    
    Message message1 = new APIMessage();
    pushNotificationService.send(message1);
    
    Message message2 = new FirebaseMessage(to, from, title, body);
    pushNotificationService.send(message2);
    
    
    
    // OR - Send a single message
    Message message = null;
    if(shouldUseAPIPushNotification()) {
        message = new APIMessage();
    } else if(shouldUseFirebase()) {
        message = new FirebaseMessage(to, from, title, body);
    }
    // Client side has zero knowledge about how the service works 
    pushNotificationService.send(message);
    
    
  6. Now you have a Module which is open to extending and close for modification. New Service Providers can be added into your module without side effect on any existing program.

There are still spaces to improve e.g. We can make the service providers self-register themselves into our module so you don't need to modify the module main class everytime we have a new Service Provider. This can be implemented by Java Service Locator pattern or by DI containers using classpath scan.

ehe888
  • 137
  • 3