Architecture - service definition

Content:

  1. Introduction
  2. The problem
  3. Analysis
  4. Example
  5. Tier specific

Introduction:

The previous article Basic architectural terminology introduced service oriented architecture and micro-services.

This article will take a deeper dive into how to design an application using a micro-service architecture.

The problem:

A key question is how many separately deployable services an application should be split up in.

To put some labels on different options:

Monolith architecture (common term)
Just one single service
Mini-service architecture (invented term)
A few services each covering a broad group of functionality - think one service package with multiple services classes each with multiple service methods
Micro-service architecture (common term)
Many services each covering a narrow group of funtionality - think one service package with one service class with multiple service methods
Nano-service architecture (invented term)
Lots of services each covering just one functionality - think one service package with one service class with one service method

The choice of option here is not trivial.

Some options may be more in fashion than other, but the reality is that there is not one option that is best for all cases. There are tradeoffs and the best option for a specific case depends on the specific context.

Analysis:

Splitting service X up in services X1,...,Xn has some potential advantages:

and some potential disadvantages:

Regarding transactional integrity the options are:

From a practical perspective a single service and simple transactions are almost always the best option.

So the key questions to ask when evaluating whether some functionality should be in N services or in a single service are:

  1. is it relevant run a different number of instances of the N services?
  2. does it make sense to have N-1 services running if 1 service are down?
  3. will any of the services be frequently updated?
  4. are there a need for transactional integrity between the N services?
  5. how many high volume interactions between the N services?

Decision logic:

Question Answer => decsion
is it relevant run a different number of instances of the N services? no => no reason to split
yes => reason to split
does it make sense to have N-1 services running if 1 service are down? no => no reason to split
yes => reason to split
will any of the services be frequently updated? no => no reason to split
yes => reason to split
are there a need for transactional integrity between the N services? no => can split
yes => should not split
how many high volume interactions between the N services? few => can split
many => should not split

This can be operationalized to a process:

  1. identify functionality F1..Fn
  2. identify bundles of functionality (Fx, Fy, ..., Fz) requiring transactional integrity
  3. identify high volume interactions dependencies between functionality Fa -> Fb
  4. identify functionality with benefits from being split out (need to be independent scalable, not required to always run, frequent upgrades)
  5. put each transactional bundle including other overlapping transactional bundles in its own service
  6. put functionality with many interactions with one of the already defined service in that service unless it significant benefits from being split out in its own service
  7. put functionality with many interactions in its own service
  8. redo step 6 for new services
  9. put functionality benefitting from being split out in its own service
  10. put all remaining functionality in a single service

Is the result perfect? Probably not. Is the result good? Maybe. Is the result relevant as a starting point? Yes.

You may have heard experts say that the best way to do micro-services is to do it the second time and wonder why that is the case if the above process works. As always the devil is in the details. Step 5-10 above are certainly doable. But step 1-4 above are the tricky ones. Getting them wrong will make the split in services wrong even when following the process.

Example:

Let us create an example - a simple web shop.

Functionality:

Context

And let us see different ways to split the application.

Monolith:

Everything in one application.

Monolith

Mini-services - naive approach:

Split in 3 services (sale, back office and data).

Mini

Micro-services - naive approach:

Split in 8 services (customer, sale, ship, reports, employee, inventory, order and card).

Micro

Nano-services:

One functionality becomes one service.

Nano

Process - manual:

We follow the process step by step.

Step 1:

identify functionality F1..Fn

We got per above:

unassigned functionality
register/edit customer,customer login,view inventory,buy,create/edit employee,employee login,ship,view reports,manage customers,manage employees,manage inventory,manage orders,charge card

Step 2:

identify bundles of functionality (Fx, Fy, ..., Fz) requiring transactional integrity

We have two transactional bundles:

Step 3:

identify high volume interactions dependencies between functionality Fa -> Fb

We have thirteen critical dependencies:

Step 4:

identify functionality with benefits from being split out (need to be independent scalable, not required to always run, frequent upgrades)

Independent scalable:

Not required to always run:

Frequent upghrades:

Step 5:

put each transactional bundle including other overlapping transactional bundles in its own service

We have two overlapping transaction bundles so we move them to a core service:

core service
buy,ship,manage inventory,manage orders,charge card
unassigned functionality
register/edit customer,customer login,view inventory,create/edit employee,employee login,view reports,manage customers,manage employees

Step 6:

put functionality with many interactions with one of the already defined service in that service unless it significant benefits from being split out in its own service

View inventory interacts a lot with core service and there are no big benefits from having it seperate, so it goes into core service.

View reports interacts a lot with core service but there is a big benefit from having it seperate due to the frequent upgrades, so it does not go to core service.

core service
buy,ship,manage inventory,manage orders,charge card,view inventory
unassigned functionality
register/edit customer,customer login,create/edit employee,employee login,view reports,manage customers,manage employees

Step 7:

put functionality with many interactions in its own service

View reports, manage customers and manage employees each has two interactions so they become services.

core service
buy,ship,manage inventory,manage orders,charge card,view inventory
view reports service
view reports
customer service
manage customers
employee service
manage employees
unassigned functionality
register/edit customer,customer login,create/edit employee,employee login

Step 8:

redo step 6 for new services

Register/edit customer and customer login go to customer service.

Create/edit employee and employee login goes to employee service. The fact that create/edit employee is not required to always run is not sufficient to split it out in a separate service.

core service
buy,ship,manage inventory,manage orders,charge card,view inventory
view reports service
view reports
customer service
manage customers,register/edit customer,customer login
employee service
manage employees,create/edit employee,employee login
unassigned functionality
(none)

Step 9:

put functionality benefitting from being split out in its own service

Nothing to do.

core service
buy,ship,manage inventory,manage orders,charge card,view inventory
view reports service
view reports
customer service
manage customers,register/edit customer,customer login
employee service
manage employees,create/edit employee,employee login
unassigned functionality
(none)

Step 10:

put all remaining functionality in a single service

Nothing to do.

core service
buy,ship,manage inventory,manage orders,charge card,view inventory
view reports service
view reports
customer service
manage customers,register/edit customer,customer login
employee service
manage employees,create/edit employee,employee login
unassigned functionality
(none)

Result:

Process

Is this a micro-service architecture? Or a mini-service architecture? Or a hybrid? I would be inclined to call it a hybrid with 1 mini-service and 3 micro-servers. But it does not matter what we label it - it only matters if it works!

Process - automated:

The process in the previous section can actually be implemented as code with just a few practical assumptions.

Code:

package archadv;

public class Functionality {
    private String name;
    private boolean scalable;
    private boolean optional;
    private boolean updateable;
    public Functionality(String name, boolean scalable, boolean optional, boolean updateable) {
        this.name = name;
        this.scalable = scalable;
        this.optional = optional;
        this.updateable = updateable;
    }
    public String getName() {
        return name;
    }
    public boolean isScalable() {
        return scalable;
    }
    public boolean isOptional() {
        return optional;
    }
    public boolean isUpdateable() {
        return updateable;
    }
    @Override
    public String toString() {
        return name;
    }
}
package archadv;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public abstract class BaseServiceSplit {
    private List<Functionality> functionalities = new ArrayList<Functionality>();
    private List<List<Functionality>> transactionalBundles = new ArrayList<List<Functionality>>();
    private Map<Functionality,List<Functionality>> dependencies = new HashMap<Functionality,List<Functionality>>();
    public BaseServiceSplit add(Functionality f) {
        functionalities.add(f);
        return this;
    }
    public BaseServiceSplit transactionBundle(Functionality... f) {
        transactionalBundles.add(new ArrayList<Functionality>(List.of(f)));
        return this;
    }
    private void onewayDependency(Functionality f1, Functionality f2) {
        List<Functionality> temp = dependencies.getOrDefault(f1, new ArrayList<Functionality>());
        temp.add(f2);
        dependencies.put(f1, temp);
    }
    public BaseServiceSplit dependency(Functionality f1, Functionality f2) {
        onewayDependency(f1, f2);
        onewayDependency(f2, f1);
        return this;
    }
    private List<Functionality> copy(List<Functionality> lstf) {
        return new ArrayList<Functionality>(lstf);
    }
    private List<Functionality> create(Functionality f) {
        return copy(List.of(f));
    }
    private boolean overlap(List<Functionality> lstf1, List<Functionality> lstf2) {
        return lstf1.stream().filter(lstf2::contains).count() > 0;
    }
    public abstract double valueSplit(int deps, Functionality f);
    public List<List<Functionality>> suggestion() {
        List<List<Functionality>> res = new ArrayList<List<Functionality>>();
        List<Functionality> work = new ArrayList<Functionality>(functionalities);
        // start with transactional bundles
        for(List<Functionality> b : transactionalBundles) {
            boolean already = false;
            for(List<Functionality> srv : res) {
                if(overlap(b, srv)) {
                    for(Functionality f : b) {
                        if(!srv.contains(f)) {
                            srv.add(f);
                            work.remove(f);
                        }
                    }
                    already = true;
                }
            }
            if(!already) {
                res.add(copy(b));
                for(Functionality f : b) {
                    work.remove(f);
                }
            }
        }
        // avoid dependencies to the extent possible
        for(Map.Entry<Functionality,List<Functionality>> e : dependencies.entrySet().stream().sorted((me1,me2) -> me2.getValue().size() - me1.getValue().size()).collect(Collectors.toList())) {
            Functionality f = e.getKey();
            List<Functionality> deps = e.getValue();
            // check if still unassigned
            if(work.contains(f)) {
                // look in existing bundles (transactional + earlier high dependency)
                int highdep = -1;
                List<Functionality> highsrv = null;
                for(List<Functionality> srv : res) {
                    int ndep = 0;
                    for(Functionality f2 : srv) {
                        if(deps.contains(f2)) {
                            ndep++;
                        }
                    }
                    if(ndep > highdep) {
                        highdep = ndep;
                        highsrv = srv;
                    }
                }
                if(valueSplit(highdep, f) < 0) {
                    // belongs in existing bundle (transactional + earlier high dependency)
                    highsrv.add(f);
                    work.remove(f);
                } else if(deps.size() > 0) {
                    // belong in new bundle
                    res.add(create(f));
                    work.remove(f);
                } else {
                    // leave it
                }
            }
        }
        // process rest
        List<Functionality> remains = new ArrayList<Functionality>();
        while(!work.isEmpty()) {
            Functionality f = work.remove(0);
            if(valueSplit(0, f) > 0) {
                // benefit from being itself => put in new bundle
                res.add(create(f));
            } else {
                // no benefit from being itself => put in remains bundle
                remains.add(f);
            }
        }
        if(!remains.isEmpty()) res.add(remains);
        return res;
    }
}
package archadv;

public class StandardServiceSplit extends BaseServiceSplit {
    @Override
    public double valueSplit(int deps, Functionality f) {
        return (f.isScalable() ? 1.0 : 0.0) + (f.isOptional() ? 0.5 : 0.0) + (f.isUpdateable() ? 1.5 : 0.0) - deps;
    }
}
package archadv;

public class Demo {
    public static void main(String[] args) {
        Functionality regedtcust = new Functionality("Register/edit customer", false, false, false);
        Functionality custlog = new Functionality("Customer login", false, false, false);
        Functionality vinv = new Functionality("View inventory", false, false, false);
        Functionality buy = new Functionality("Buy", false, false, false);
        Functionality ship = new Functionality("Ship", false, false, false);
        Functionality vrep = new Functionality("View reports", false, true, true);
        Functionality emplog = new Functionality("Employee login", false, false, false);
        Functionality creedtemp = new Functionality("Create/edit employee", false, true, false);
        Functionality mgcust = new Functionality("Manage customers", false, false, false);
        Functionality mginv = new Functionality("Manage inventory", false, false, false);
        Functionality mgord = new Functionality("Manage orders", false, false, false);
        Functionality card = new Functionality("Charge card", false, false, false);
        Functionality mgemp = new Functionality("Manage employee", false, false, false);
        BaseServiceSplit archdes = new StandardServiceSplit();
        archdes.add(regedtcust)
               .add(custlog)
               .add(vinv)
               .add(buy)
               .add(ship)
               .add(vrep)
               .add(emplog)
               .add(creedtemp)
               .add(mgcust)
               .add(mginv)
               .add(mgord)
               .add(card)
               .add(mgemp)
               .transactionBundle(buy, mginv, mgord, card)
               .transactionBundle(ship, mginv, mgord)
               .dependency(regedtcust, mgcust).dependency(custlog, mgcust)
               .dependency(vinv, mginv).dependency(vinv, mgord)
               .dependency(buy, mginv).dependency(buy, mgord).dependency(buy, card)
               .dependency(ship,  mginv).dependency(ship,  mgord)
               .dependency(vrep,  mginv).dependency(vrep,  mgord)
               .dependency(creedtemp, mgemp).dependency(emplog, mgemp);
        for(List<Functionality> srv : archdes.suggestion()) {
            System.out.println(srv);
        }
        System.out.println(archdes.suggestion());
    }
}

Output:

[Buy, Manage inventory, Manage orders, Charge card, Ship, View inventory]
[Manage employee, Employee login, Create/edit employee]
[View reports]
[Manage customers, Register/edit customer, Customer login]

Same result as with the manual process!!

And let me emphasize: the above code is not an AI coming up with a good architecture - it is architects with good domain knowledge that has done the proper analysis (step 1-4) and the program just apply some simple rules (based on step 5-10) to that input.

Comparison:

Model Number services Network hops for view inventory Network hops for buy Network hops for ship Cross service transaction
Monolith 1 1 + 2 = 3 1 + 3 = 4 1 + 2 = 3 No
Mini-services - naive approach 3 1 + 2 + 2 = 5 1 + 3 + 3 = 7 1 + 2 + 2 = 5 Yes
Micro-services - naive approach 8 1 + 2 + 2 = 5 1 + 3 + 3 = 7 1 + 2 + 2 = 5 Yes
Nano-services 13 1 + 2 + 2 = 5 1 + 3 + 3 = 7 1 + 2 + 2 = 5 Yes
Process 4 1 + 2 = 3 1 + 3 = 4 1 + 2 = 3 No

My subjective rating:

  1. Process (best possible)
  2. Monolith (not maintenance friendly)
  3. Micro-services - naive approach (problems, but on the right track - customer service and employee service are right)
  4. Mini-services - naive approach (problems)
  5. Nano-services (problems)

The big gap is betwen #2 and #3.

We see that attempts to split up the application without proper analysis can end up being worse than the old monolith.

And to reiterate: there is no guarantee that the process will end up with the best architecture given all knowledge. It is just some basic rules applied to a small subset of all relevant information. But it should be close enough to the best architecture to work as a starting point for discussions between the architects that provided the input.

Tier specific:

A typical application will have:

A lot of micro-service architecture are actually only micro-service in back end, but monolith in front end and database.

Front end:

Splitting up front end can be a particular hard problem. Usually the "single pane of glass" approach are preferred over many separate UI's.

Possible approaches:

One UI:

one UI

Multiple UI's:

multi UI

Composite UI:

composite UI

Recommendation: if acceptable go with separate UI's else go with container and plugin model.

The reason is that otherwise it becomes too expensive to test changes to a single service because UI changes spill over to other UI's and use cases:

One UI:

test of one UI

Multiple UI's:

test of multi UI

Composite UI:

test composite UI

Database:

Databases can also be hard to split up.

For various reasons:

Recommendation: if possible aplit up the database if possible.

The reason is that shared data create a potential hidden dependency between the back end services. You may not be able to freely change a service independently of other services, because the change may require change to data structures in the database shared with other services.

Databases can be split up both logical as separate databases/schemas and physical as separate database servers.

But logical or physical split is usually more a practical decision due to the fact that most database servers (relational) scale vertical only and are able to control resource allocation to logical databases/schemas.

Article history:

Version Date Description
1.0 December 9th 2023 Initial version

Other articles:

See list of all articles here

Comments:

Please send comments to Arne Vajhøj