Use Case Description
Sometimes there is the usecase for a system has the same basic requirements but needs some different logic in the core for different customers. The question is: Do the differences warrant a new micro service or do you put the logic in the same? Basically the question is a monolith or a zoo of micro services?
The answer depends on the requirements and both extremes are probably wrong.
For requirements that are very similar the Spring feature to filter classes can be used with a condition based on the selected profile. The Profiles are a comma separated list that can be used to decide what classes to filter out. For Spring the classes are then not available and only the endpoints and services that are needed can be injected.
The Angular frontend can get a Config Service that returns the profiles and switches on features based on it. The routing can be based on the profiles too and use Angulars lazy loaded modules.
That enables keeping the common parts and adding some different features for each customer. But there needs to be a warning:
If you do not have good information on how many different requirements will come over time think of more small micro services. Then you do not have to explain why after a certain point in time the application has grown too big and you need to split it now. This is more of a cultural than a technical consideration.
Implementing the Backend
The project that is used as example is the AngularPortfolioMgr. The Profiles ‘dev’ and ‘prod’ are used in this example but other Profiles could be added and used. The separate classes are in packages that are prefixed ‘dev’ or ‘prod’. The filter is implemented in the ComponentScanCustormFilter class.
public class ComponentScanCustomFilter implements TypeFilter, EnvironmentAware { private Environment environment; @Override public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException { String searchString = List.of(this.environment.getActiveProfiles()) .stream().anyMatch(profileStr -> profileStr.contains("prod")) ? "ch.xxx.manager.dev." : "ch.xxx.manager.prod."; ClassMetadata classMetadata = metadataReader.getClassMetadata(); return classMetadata.getClassName().contains(searchString); } @Override public void setEnvironment(Environment environment) { this.environment = environment; } } The setter at the bottom implements EnvironmentAware and gets the environment injected at that early stage of application startup.
The match method implements the Typefilter and sets the class filter according to the Profile in the environment. True is returned for the classes that are excluded.
The class filter is used in the ManagerApplication class:
@SpringBootApplication @ComponentScan(basePackages = "ch.xxx.manager", excludeFilters = @Filter(type = FilterType.CUSTOM, classes = ComponentScanCustomFilter.class)) public class ManagerApplication { public static void main(String[] args) { SpringApplication.run(ManagerApplication.class, args); } } The ‘@ComponentScan’ annotation sets the basePackages where the filer works. The property ‘excludeFilters’ adds the ComponentScanCustomFilter. That excludes either the packages with ‘dev.*’ or ‘prod.*’. That makes it impossible to use classes in that packages.
To get a http return code 401 for the excluded ‘/rest/dev’ or ‘/rest/prod’ endpoints the Spring Security config in the WebSecurityConfig class needs to be updated:
private static final String DEVPATH="/rest/dev/**"; private static final String PRODPATH="/rest/prod/**"; @Value("${spring.profiles.active:}") private String activeProfile; @Override protected void configure(HttpSecurity http) throws Exception { final String blockedPath = this.activeProfile.toLowerCase() .contains("prod") ? DEVPATH : PRODPATH; http.httpBasic().and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().authorizeRequests() .antMatchers("/rest/config/**").permitAll() .antMatchers("/rest/auth/**").permitAll() .antMatchers("/rest/**").authenticated() .antMatchers(blockedPath).denyAll() .antMatchers("/**").permitAll() .anyRequest().authenticated() .and().csrf().disable() .headers().frameOptions().sameOrigin() .and().apply(new JwtTokenFilterConfigurer(jwtTokenProvider)); } The ‘blockedPath’ is set according of the activeProfile. It then sets the excluded path to ‘denyAll()’ with an AntMatcher. The endpoint for the ConfigController(‘/rest/config/profiles’) is set to ‘permitAll()’ to enable early access to the value. The added paths inherit the ‘authenticated()’ value.
The ConfigController provides the endpoint that the frontend uses to get the profiles.
The different requirements can be implemented in classes like the ProdAppInfoService in the ‘prod’ packages and DevAppInfoService in the ‘dev’ packages. The different endpoints can be implemented in classes like the ProdAppInfoController in the ‘prod’ packages and the DevAppInfoController in the ‘dev’ packages.
ArchUnit can be used to check the package dependencies during build time. A setup can be found in the MyArchitectureTests class.
Implementing the Frontend
The Angular frontend has a ConfigService that retrieves and then caches the Profiles:
@Injectable({ providedIn: 'root' }) export class ConfigService { private profiles: string = null; constructor(private http: HttpClient) { } getProfiles(): Observable<string> { if(!this.profiles) { return this.http.get(`/rest/config/profiles`, {responseType: 'text'}) .pipe(tap(value => this.profiles = value)); } else { return of(this.profiles); } } } The ConfigService retrieves the Profiles once and then caches it forever.
The ConfigService is used in the OverviewComponent. The ‘ngOnInit’ method initializes the ‘profiles’ property:
... private profiles: string = null; ... ngOnInit() { ... this.configService.getProfiles() .subscribe(value => this.profiles = !value ? 'dev' : value); } The injected configService is used to get the profiles. If the result is empty the ‘dev’ value is used otherwise the returned value.
The ‘showConfig()’ method of the OverviewComponent opens a dialog according to the ‘profiles’ property:
showConfig(): void { if (!this.dialogRef && this.profiles) { if(!!this.dialogSubscription) { this.dialogSubscription.unsubscribe(); } const myOptions = { width: '700px' }; this.dialogRef = this.profiles.toLowerCase().includes('prod') ? this.dialog.open(ProdConfigComponent, myOptions) : this.dialog.open(DevConfigComponent, myOptions); this.dialogSubscription = this.dialogRef.afterClosed().subscribe(() => this.dialogRef = null); } } The ‘showConfig()’ method first checks that no dialog is open and the ‘profiles’ property is set. Then it opens the dialog with the ProdConfigComponent or the DevConfigComponent according to the ‘profiles’ property.
The ProdConfigComponent injects the ProdAppInfoService to retrieve the classname from the ‘/rest/prod/app-info/class-name’ endpoint and displays it.
The DevConfigComponent injects the DevAppInfoService to retrieve the classname from the ‘/rest/dev/app-info/class-name’ endpoint and displays is it.
The differences in the frontend can be implemented like in this example. It is possible to route according to the Profiles of the ConfigService and to use lazy loaded modules in Angular. In the AppRoutingModule lazy loaded modules are used. Then the user does only load the needed Angular Modules.
Conclusion
Switching on features with Spring Profiles enables the save separation of different requirements in an application. Only the required features are available and the frontend can be switched according to the requirements too. Lazy loaded Angular Modules would enable the the users to load only the needed parts. This design is useful if the differences in the requirements are small(and will stay small) and should have a clean separation.