javaoopdesign-patternssolid-principlessingle-responsibility-principle

Trying to understand SRP when we seggregate responsibilities into different classes


I'm trying to understand SRP principle and most of the sof threads didn't answer this particular query I'm having,

Use-case

I'm trying to send an email to the user's email address to verify himself whenever he tries to register/create an user-account in a website.

Without SRP

class UserRegistrationRequest {
    String name;
    String emailId;
}
class UserService {
    Email email;

    boolean registerUser(UserRegistrationRequest req) {
        //store req data in database
        sendVerificationEmail(req);
        return true;
    }

    //Assume UserService class also has other CRUD operation methods()    

    void sendVerificationEmail(UserRegistrationRequest req) {
        email.setToAddress(req.getEmailId());
        email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
        email.send();
    }
}

The above class 'UserService' violates SRP rule as we are clubbing 'UserService' CRUD operations and triggering verification email code into 1 single class.

Hence I do,

With SRP

class UserService {
    EmailService emailService;

    boolean registerUser(UserRegistrationRequest req) {
        //store req data in database
        sendVerificationEmail(req);
        return true;
    }

    //Assume UserService class also has other CRUD operation methods()    

    void sendVerificationEmail(UserRegistrationRequest req) {
        emailService.sendVerificationEmail(req);
    }
}

class EmailService {
    void sendVerificationEmail(UserRegistrationRequest req) {
        email.setToAddress(req.getEmailId());
        email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
        email.send();
    }

But even 'with SRP', UserService as a class again holds a behaviour of sendVerificationEmail(), though this time it didn't hold the entire logic of sending the email.

Isn't it again we are clubbing crud operation's and sendVerificationEmail() into 1 single class even after applying SRP?


Solution

  • Your feeling is absolutely right. I agree with you.

    I think your problem starts with your naming style, since you seem to be quite aware what SRP means. Class names like '...Service' or '...Manager' carry a very vague meaning or semantics. They describe a more generalized context or concept. In other words a '...Manager' class invites you to put everything inside and it still feels right, because it's a manager.

    When you get more concrete by trying to focus on the true concepts of your classes or their responsibilities, you will automatically find bigger names with a stronger meaning or semantics. This will really help you to split up classes and to identify responsibilities.

    SRP:

    There should never be more than one reason to change a certain module.

    You could start with renaming the UserService to UserDatabaseContext. Now this would automatically force you to only put database related operations into this class (e.g. CRUD operations).

    You even can get more specific here. What are you doing with a database? You read from and write to it. Obviously two responsibilities, which means two classes: one for read operations and another responsible for write operations. This could be very general classes that can just read or write anything. Let's call them DatabaseReader and DatabaseWriter and since we are trying to decouple everything we are going to use interfaces everywhere. This way we get the two IDatabaseReader and IDatabaseWriter interfaces. This types are very low level since they know the database (Microsoft SQL or MySql), how to connect to it and the exact language to query it (using e.g. SQL or MySql):

    // Knows how to connect to the database
    interface IDatabaseWriter {
      void create(Query query);
      void insert(Query query);
      ...
    }
    
    // Knows how to connect to the database
    interface IDatabaseReader {
      QueryResult readTable(string tableName);
      QueryResult read(Query query);
      ...
    }
    

    On top, you could implement a more specialized layer of read and write operations, e.g. user related data. We would introduce a IUserDatabaseReader and a IUserDatabaseWriter interface. This interfaces don't know how to connect to the database or what type of database is used. This interfaces only know what information is required to read or write user details (e.g. using a Query object that is transformed into a real query by the low level IDatabaseReader or IDatabaseWriter):

    // Knows only about structure of the database (e.g. there is a table called 'user') 
    // Implementation will internally use IDatabaseWriter to access the database
    interface IUserDatabaseWriter {
      void createUser(User newUser);
      void updateUser(User user);
      void updateUserEmail(long userKey, Email emailInfo); 
      void updateUserCredentials(long userKey, Credential userCredentials); 
      ...
    }
    
    // Knows only about structure of the database (e.g. there is a table called 'user') 
    // Implementation will internally use IDatabaseReader to access the database
    interface IUserDatabaseReader {
      User readUser(long userKey);
      User readUser(string userName);
      Email readUserEmail(string userName);
      Credential readUserCredentials(long userKey);
      ...
    }
    

    We are still not done with the persistence layer. We can introduce another interface IUserProvider. The idea is to decouple the database access from the rest of our application. In other words we consolidate the user related data query operations into this class. So, IUserProvider will be the only type that has direct access to the data layer. It forms the interface to the application's persistence layer:

    interface IUserProvider {
      User getUser(string userName);
      void saveUser(User user);
      User createUser(string userName, Email email);
      Email getUserEmail(string userName);
    }
    

    The implementation of IUserProvider. The only class in the whole application that has direct access to the data layer by referencing IUserDatabaseReader and IUserDatabaseWriter. It wraps reading and writing of data to make data handling more convenient. The responsibility of this type is to provide user data to the application:

    class UserProvider {
      IUserDatabaseReader userReader;
      IUserDatabaseWriter userWriter;
        
        // Constructor
        public UserProvider (IUserDatabaseReader userReader, 
              IUserDatabaseWriter userWriter) {
          this.userReader = userReader;
          this.userWriter = userWriter;
        }
    
      public User getUser(string userName) {
        return this.userReader.readUser(username);
      }
    
      public void saveUser(User user) {
        return this.userWriter.updateUser(user);
      }
    
      public User createUser(string userName, Email email) {
        User newUser = new User(userName, email);
        this.userWriter.createUser(newUser);
        return newUser;
      }
    
      public Email getUserEmail(string userName) {
        return this.userReader.readUserEmail(userName);
      }
    }
    

    Now that we tackled the database operations we can focus on the authentication process and continue to extract the authentication logic from the UserService by adding a new interface IAuthentication:

    interface IAuthentication {
      void logIn(User user)
      void logOut(User);
      void registerUser(UserRegistrationRequest registrationData);
    } 
    

    The implementation of IAuthentication implements the special authentication procedure:

    class EmailAuthentication implements IAuthentication {
      EmailService emailService;
      IUserProvider userProvider;
    
    // Constructor
      public EmailAuthentication (IUserProvider userProvider, 
          EmailService emailService) {
        this.userProvider = userProvider;
        this.emailService = emailService;
      }
    
      public void logIn(string userName) {
        Email userEmail = this.userProvider.getUserEmail(userName);
        this.emailService.sendVerificationEmail(userEmail);
      }
    
      public void logOut(User user) {
        // logout
      }
    
      public void registerUser(UserRegistrationRequest registrationData) {
        this.userProvider.createNewUser(registrationData.getUserName, registrationData.getEmail());
    
        this.emailService.sendVerificationEmail(registrationData.getEmail());    
      }
    }
    

    To decouple the EmailService from the EmailAuthentication class, we can remove the dependency on UserRegistrationRequest by letting sendVerificationEmail() take an Email` parameter object instead:

    class EmailService {
      void sendVerificationEmail(Email userEmail) {
        email.setToAddress(userEmail.getEmailId());
        email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
        email.send();
    }
    

    Since the authentication is defined by an interface IAuthentication, you can create a new implementation at any time when you decide to use a different procedure (e.g. WindowsAuthentication), but without modifying existing code. This will also work with the IDatabaseReader and IDatabaseWriter once you decide to switch to a different database (e.g. Sqlite). The IUserDatabaseReader and IUserDatabaseWriter implementations will still work without any modification.

    With this class design, you now have exactly one reason to modify each existing type:

    Now everything is cleanly separated. Authentication doesn't mix with CRUD operations. We have an additional layer between application and persistence layer to add flexibility regarding the underlying persistence system. So CRUD operations don't mix with the actual persistence operations.

    As a tip: in future you better start with the thinking (design) part first: what must my application do?

    As you can see, you can start to implement each step or requirement separately. But this doesn't mean each requirement is realized by exactly one class. As you remember, we split up database access into four responsibilities or classes: read and write to real database (low level), read and write to database abstraction layer, to reflect concrete use cases (high level). Using interfaces adds flexibility and testability to the application.