How to Share the Business Logic
in a Polyglot App
Mixing Java and TypeScript

Welcome to the Furets!

@ozangunalp – Ozan Gunalp

  • PhD in Computer Science, Java, OSGi, IoT, CI/CD
  • JenkinsPipelineUnit
  • Software Architect at LesFurets.com


@gdigugli – Gilles Di Guglielmo

  • Designer of sweet cooked software since 1999
  • Software Architect at LesFurets.com

  • 5 Insurance Products : Car, Health, Home, Bike, Loan
  • Other Comparison Products : Energy, Credit, ISP, ...
  • 1 codebase, 500k lines of code, 60k unit tests, 150 selenium tests
  • 25+ Developers, 2 DevOps, 3 Architects
  • 30+ production servers including Load balancers, Frontend, Backend, Databases, BI
  • Daily release to production
  • 10+ years of code history
  • 3M quotes/year, 40% of market share, 4M of customers

Introduction

LesFurets service orchestration

LesFurets service orchestration

Insurers partners

We have 100 live insurers, on 5 products, each with business validation rules, that filter prospects based on their profile

Insurer exclusions

Hierarchy of 492 legacy classes, no governance or auditability

Use case: insurer exclusions

Insurer exclusions based on object model (legacy code)

                
public void check(FieldContext context, FormuleMoto formule, Conducteur conducteur,
                  Vehicule vehicule, Void unused, Besoins besoins,
                  Set<EAbTestingScenario> scenarios)
                  throws ExclusionException {
    if (besoins == null) {
        return;
    }
    if (besoins.getDateDebutContrat() == null) {
        return;
    }
    if (!DateHelper.isAfter(besoins.getDateDebutContrat(),
                    DateHelper.ajouteJoursADate(DateHelper.getToday(), NBR_JOURS),
                    DateHelper.EPrecision.jour)) {
        throw new ExclusionException(DATE_EFFET_PLUS_60_JOURS);
    }
}
                
              

Use case: goal

  • compliance: the rules correspond to the specification documents
  • auditability: understand a rule without looking at the code
  • governance: maintenance of the rules catalogue
  • clarity: productivity for developers

Same rule, more fluent:

                
public ExclusionRule exclusionRule() {
    return DOOV.when(dateContrat().after(todayPlusDays(60)))
               .exclusionRule();
}
                
              

Meet dOOv!

Domain Object Oriented Validation

a fluent API for typesafe domain model validation

dOOv : Under the hood

                DOOV.when(accountCompany.eq(Company.LES_FURETS)
            .and(accountPhoneNumber.startsWith("+33"))).validate();
              

DEMO

Export to text, markdown,...

                DOOV.when(accountCompany.eq(Company.LES_FURETS)
            .and(accountPhoneNumber.startsWith("+33"))).validate();
              

Markdown

                
* rule
  * when
    * account company = 'LES_FURETS' and
    * account phone number starts with '+33'
  * validate
                
              

Text

                
rule when (account company = LES_FURETS and account phone number starts with '+33') validate
                
              

Runtime Rule Catalog & Business Oriented statistics

LesFurets service Mapping

Domain Object Oriented Mapping

Next step is extending the DSL to create a
bean mapping framework

Features the same AST to text and statistics functionalities

Use Case : Object-to-Object Mapping

source model

                    
class Model {
  User user;
  Account account;
}

class User {
  String firstName;
  String lastName;
  LocalDate birthdate;
}

class Account {
  String email;
  boolean acceptEmail;
  Country country;
}
                    
                  

target model

                    
class Employee {
  String fullName;
  String email;
  int age;
  String country;
  String company;
}
                    
                  

Use Case : Object-to-Object Mapping

                      Model model = ...;
Employee employee = new Employee();
// declarative mapping rule
MappingRule rules = mappings(
  when(accountAcceptEmail.isTrue())
      .then(map(accountEmail).to(employeeEmail)),

  map(userFirstName, userLastName)
      .using(biConverter((f, l) -> f + " " + l, "", "combine names"))
      .to(employeeFullname),

  map(userBirthdate.ageAt(today())).to(employeeAge),

  map(accountCountry)
      .using(converter(c -> c.name(), "country name"))
      .to(employeeCountry)
);
// then execute the mapping
rules.executeOn(model, employee)
                    
                

Use Case : Object-to-Object Mapping

                      Model model = ...;
Employee employee = new Employee();
// declarative mapping rule
MappingRule rules = mappings(
  when(accountAcceptEmail.isTrue())
      .then(map(accountEmail).to(employeeEmail)),

  map(userFirstName, userLastName)
      .using(biConverter(
            (f, l) -> f + " " + l, "", "combine names"))
      .to(employeeFullname),

  map(userBirthdate.ageAt(today())).to(employeeAge),

  map(accountCountry)
      .using(converter(c -> c.name(), "country name"))
      .to(employeeCountry)
);
// then execute the mapping
rules.executeOn(model, employee)
                

LesFurets Frontend Rules

LesFurets Web Form

Web Form Rules

Initial values, options

                    
map(['Napolitan', 'New York',
     'StLouis', 'Pan',
     'DeepDish', 'Sicilian'])
  .to(crustOptions);
                    
                  

Update store with value change

                    
map(fieldValue).to(city),

when(city.eq('napoli'))
.then(
  map(['Napolitan'])
    .to(crustOptions)
)
                    
                  

Update field from store: value, options, visibility

                    
when(city.eq('napoli'))
.then(
  map('L').to(size)
)

map(size).to(fieldValue)
                    
                  

Validate field value

                    
when(
  matchAll(
    city.eq('napoli'),
    crust.eq('Pan')
  )).validate()
                    
                  

Frontend Rules @ LesFurets

1000 fields with more than 3100 form interaction rules
spanned over 600 classes

No governance or auditability

Meet doov-TS

dOOv in TypeScript

https://github.com/doov-io/doov-ts

Why TypeScript?

Superset of JS syntax with ESNext features

const & let

                    
const name: string = 'Bob';
let age: number = 26;
i++;
name = 'Alice';
// TS2588: Cannot assign to 'name' because it is a constant.
                    
                  

Superset of JS syntax with ESNext features

Arrow functions

                    
const reverse = (v: string) => {
  return v.split().reverse().join('');
}
                    
                  

Superset of JS syntax with ESNext features

Classes, static methods, access modifiers

                    
class Square extends Rectangle {
  readonly colour: string;

  constructor(a: number, colour: string) {
    super(a, a);
    this.colour = colour;
  }

  public static area(r: Rectangle) {
    return r.a * r.b;
  }
}
                  

Superset of JS syntax with ESNext features

Promises

                    
import fs from 'fs';

function readFileAsync(filename: string): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    fs.readFile(filename, (err, result) => {
      err ? reject(err) : resolve(result);
    });
  });
}
                  

Optional strong type system

Interface, Generics, Type Union & Intersection, Type Guards...

                    
interface Square { kind: "square"; size: number; }

interface Rectangle { kind: "rectangle"; width: number; height: number; }

type Shape = Square | Rectangle

function area(s: Shape) {
    return s.kind === "square" ? s.size * s.size : s.width * s.height;
}
                    
                  

Compiles to JS

TS

                    
const num: number = 123;

interface WithStr {
  num: number;
  str: string;
}

function attachStr(num: number): WithStr {
  return { num: num, str: String(num) };
}
                    
                  

JS

                    
var num = 123;
function attachStr(num) {
    return { num: num, str: String(num) };
}
                    
                  

Why TypeScript?

Types are necessary to scale a JS app
Flow, ReasonML

Hides away questions about ES language level and target environment into compiler configuration
tsconfig.json

TypeScript coming from Java

TypeScript syntax is very similar to Java
(except for the right-hand side type annotations)

Method overloading works differently

No default methods on Interfaces

Learn more about TypeScript

TypeScript Deep Dive

http://basarat.gitbooks.io/typescript/

DEMO doov-TS

https://codesandbox.io/s/github/ozangunalp/doov-ts-example

Migrating business rules to dOOv

Business rules are easier to code, grasp and govern

Debugging is not obvious
Code coverage is misleading

Enjoy dOOv.io

http://www.dOOv.io

dOOv & dOOv-TS (framework and examples)
http://github.com/doov-io

dOOv-TS Example
https://codesandbox.io/s/github/ozangunalp/doov-ts-example

Slides
http://github.com/doov-io/doov-docs

Open Source & Apache Licence

Try and contribute!

Appendices

DSL

"A domain-specific language (DSL) is a computer language specialized to a particular application domain. This is in contrast to a general-purpose language (GPL), which is broadly applicable across domains"

Beyond Bean Validation: syntax versus consistency

 

Bean Validation allows syntax validation
 
 
Bean Validation rules are written on the model
 
 
Bean Validation uses reflection
dOOv allows consistency checks
 
 
dOOv rules are writtent outside of the model, allowing complex checks
 
 
dOOv uses code generation

dOOv ecosystem

How to write a validation rule?

key model

                    
// Root class of model
class Model {
  User user;
}

// Add key named EMAIL
enum ModelFieldId {
  EMAIL;
}

// Annotate email field
class User {
  @Path(field = EMAIL
        readable = ...)
  String email;
}
                    
                  

code generate

                    
// dOOv typed field class
class DslModel {
  StringFieldInfo userEmail;
}
                    
                  

write rules

                    
// Create rules by using
// generated fields
// in DslModel
DslModel
  .when(userEmail.eq(...))
  .validate()
  // Optionaly add rules
  // to a registry
  .registerOn(DEFAULT);
                    
                  

How to validate a model

get model

                    
// Get model from somewhere
// or instanciate it
User user = new User();
user.setEmail("e@mail.com");

Model model = new Model();
model.setUser(user);
                    
                  

execute

                    
// Use executeOn method
DslModel.when(email.matches(...))
        .validate()
        .executeOn(model);

// Or use the registry
DEFAULT.stream()
  .map(rule -> rule.executeOn(model));
                    
                  

Why a fluent API?

Java is verbose, but you can reduce the noise and write code like natural language with a fluent API

                
// JUnit API
assertEquals(9, fellowshipOfTheRing.size());
assertTrue(fellowshipOfTheRing.contains(frodo, sam));
assertFalse(fellowshipOfTheRing.contains(sauron));

// AssertJ API (fluent)
assertThat(fellowshipOfTheRing).hasSize(9)
                               .contains(frodo, sam)
                               .doesNotContain(sauron);
                
              

Fluent API

New elements in Java 8 makes it easier to write a fluent API

                
// java.util.function (io.doov.core.dsl.impl.LogicalBinaryCondition)
left.predicate().and(right.predicate()).test(model, context)

// java.util.stream (io.doov.core.dsl.impl.LogicalNaryCondition)
steps.stream().anyMatch(s -> s.predicate().test(model, context))

// lambda and method reference (io.doov.core.dsl.impl.NumericCondition)
predicate(greaterThanMetadata(field, value),
          (model, context) -> Optional.ofNullable(value),
          (l, r) -> greaterThanFunction().apply(l, r));
                
              

Fluent API

Many popular libraries propose fluent APIs like
jOOQ, AssertJ, Apache Spark, etc.

                
Dataset<Row> averagePrice = prices
        .filter(value.<String>getAs("insurer")
                     .equals("COOL insurer"))
        .groupBy("product")
        .agg(avg("price").as("average"))
        .orderBy(desc("average"));
                
              

Syntax tree

Why a syntax tree?

Makes readable text generation possible:
we can output a multi-language rules catalog
in multiple formats (text, markdown, HTML, etc.)

Execution statistics

We make daily statistics that helps us shape the business,
by removing or tweaking rules as needed

Combined 'and' & 'or' operators

Validate that a profile
has at least 18 years when their country is France
and their phone number starts with '+33'
has at least 21 years when their country is Canadian
and their phone number starts with '+1'

                      
    DOOV.when(userBirthdate.ageAt(today()).greaterThan(18)
            .and(accountCountry.eq(Country.FR)
            .and(accountPhoneNumber.startsWith("+33")))
            .or(userBirthdate.ageAt(today()).greaterThan(21)
                  .and(accountCountry.eq(Country.CAN)
                  .and(accountPhoneNumber.startsWith("+1")))))
        .validate();
                      
                    

Combined 'and' & 'or' operators

                    
    model.getUser().setBirthDate(LocalDate.now().minusYears(22));
    model.getAccount().setCountry(Country.FR);
    
    ValidationRule rule = DOOV
        .when(userBirthdate.ageAt(today()).greaterThan(18)
            .and(accountCountry.eq(Country.FR)
            .and(accountPhoneNumber.startsWith("+33")))
            .or(userBirthdate.ageAt(today()).greaterThan(21)
                  .and(accountCountry.eq(Country.CAN)
                  .and(accountPhoneNumber.startsWith("+1")))))
        .validate();
    
    Result result = rule.withShortCircuit(false).executeOn(wrapper);
    System.out.println("> " + result.getFailureCause());
                    
                  

                    
    > account phone number starts with '+33'
        or (account country = CAN and
            account phone number starts with '+1')
                    
                  

Combined 'matchAll' & 'matchAny' operators

Validate that a profile
has at least 18 years when their country is France
and their phone number starts with '+33'
has at least 21 years when their country is Canadian
and their phone number starts with '+1'

                      
    DOOV.when(matchAny(
            matchAll(userBirthdate.ageAt(today()).greaterThan(18),
                     accountCountry.eq(Country.FR),
                     accountPhoneNumber.startsWith("+33")),
            matchAll(userBirthdate.ageAt(today()).greaterThan(21),
                     accountCountry.eq(Country.CAN),
                     accountPhoneNumber.startsWith("+1"))))
         .validate();
                     
                    

Combined 'matchAll' & 'matchAny' operators

                    
    model.getUser().setBirthDate(LocalDate.now().minusYears(22));
    model.getAccount().setCountry(Country.FR);
    
    ValidationRule rule = DOOV
        .when(matchAny(
            matchAll(userBirthdate.ageAt(today()).greaterThan(18),
                     accountCountry.eq(Country.FR),
                     accountPhoneNumber.startsWith("+33")),
            matchAll(userBirthdate.ageAt(today()).greaterThan(21),
                     accountCountry.eq(Country.CAN),
                     accountPhoneNumber.startsWith("+1"))))
        .validate();
    
    Result result = rule.withShortCircuit(false).executeOn(wrapper);
    System.out.println("> " + result.getFailureCause());
                    
                  

                    
    > match any [account phone number starts with '+33',
                 match all [account country = CAN,
                            account phone number starts with '+1']]
                    
                  

'anyMatch' operator for enumerated values

Validate that a profile country is Canadian or French

                    
    DOOV.when(accountCountry.anyMatch(Country.CAN, Country.FR)).validate();
                    
                  

'anyMatch' operator for enumerated values

                    
    model.getAccount().setCountry(Country.UK);
    
    ValidationRule rule = DOOV
        .when(accountCountry.anyMatch(Country.CAN, Country.FR))
        .validate();
    
    Result result = rule.withShortCircuit(false).executeOn(wrapper);
    System.out.println("> " + result.getFailureCause());
                    
                  

                    
    > account country != UK
                    
                  

Appendices: rules design

Going further : Implementing rules

              
enum Company {
    BLABLACAR, CANAL_PLUS, DAILYMOTION,
    LES_FURETS, MEETIC, OODRIVE,
}
              
            

Validate that the company of an account should NOT be
Dailymotion or Blablacar

              
DOOV.when(accountCompany.noneMatch(DAILYMOTION, BLABLACAR)).validate();
              
            

Implementing boolean logic

              
¬ DAILYMOTION ∧ ¬ BLABLACAR
// is equivalent to
¬ ( DAILYMOTION ∨ BLABLACAR )
// is equivalent to
LES_FURETS ∨ CANAL_PLUS ∨ MEETIC ∨ OODRIVE
              
            

De Morgan's laws
https://en.wikipedia.org/wiki/De_Morgan's_laws
https://en.wikipedia.org/wiki/Conjunctive_normal_form

Failure cause with noneMatch

                
model.getAccount().setCompany(DAILYMOTION);

ValidationRule rule = DOOV
      .when(accountCompany.noneMatch(DAILYMOTION, BLABLACAR))
      .validate();
Result result = rule.withShortCircuit(false).executeOn(model);
System.out.println("> " + result.getFailureCause());
                  
                

                  
  > account company match none  : DAILYMOTION, BLABLACAR
                  
            

Failure cause with notEq + and

                
DOOV.when(accountCompany.notEq(DAILYMOTION)
        .and(accountCompany.notEq(BLABLACAR))).validate();
            
          

                    
  > account company != DAILYMOTION
                    
              

Conjuctive Normal Form (CNF)

Failure cause with not + or

                  
DOOV.when(accountCompany.eq(DAILYMOTION)
        .or(accountCompany.eq(BLABLACAR)).not()).validate();
              
            

                      
    > not (account company = DAILYMOTION or account company = BLABLACAR)
                      
                

Failure cause with anyMatch

                    
DOOV.when(accountCompany.anyMatch(LES_FURETS, CANAL_PLUS, MEETIC, OODRIVE))
        .validate();
                
              

                
> account company != DAILYMOTION
                
              

Failure cause with eq + or

                      
DOOV.when(accountCompany.eq(LES_FURETS).or(accountCompany.eq(CANAL_PLUS)
        .or(accountCompany.eq(MEETIC).or(accountCompany.eq(OODRIVE)))))
                .validate();
                  
                

                  
  > account company = LES_FURETS or (account company = CANAL_PLUS 
         or (account company = MEETIC or account company = OODRIVE))
                  
                

Speed is not enough

                  
Benchmark                                     Mode  Cnt     Score      Error   Units
noneMatch                                     thrpt   20  8775.818 ± 148.951  ops/ms
notEq + and  [failure cause OK]               thrpt   20  5022.391 ± 147.550  ops/ms
not + or                                      thrpt   20  3022.433 ± 586.881  ops/ms
anyMatch     [failure cause OK]               thrpt   20  6002.415 ±  94.531  ops/ms
eq + or                                       thrpt   20  1855.837 ±  50.183  ops/ms
                  
                

                    
Benchmark                                     Mode  Cnt     Score      Error   Units
noneMatch    with short circuit               thrpt   20  6839.429 ± 262.318  ops/ms
notEq + and  with short circuit               thrpt   20  7397.586 ± 252.090  ops/ms
not + or     with short circuit               thrpt   20  5084.227 ± 450.013  ops/ms
anyMatch     with short circuit               thrpt   20  6275.185 ±  56.600  ops/ms
eq + or      with short circuit               thrpt   20  1820.198 ±  60.300  ops/ms
                    
                  

Writing rules in CNF provides the best performance and failure causes