June 9, 2019

An extensible DRY CRUD pattern in OCaml

Here’s how I designed my structure to share portable code between client and server while keeping database I/O on the server side, with a developer-friendly API which makes impossible states unrepresentable.

I’ll be exploring with the example concept of “users” with some fields. The simplest, unsafe representation would be:

type user =
{ id : int option
; password : string
; email : string
; name : string
; company : string
; url : string
}

This allows plenty of impossible states such as invalid e-mail addresses and non-existent database Ids.

Custom Types For Custom Validation

At minimum, I want to enforce some validation by wrapping types which are too broad, forcing the use of creator functions. Let’s also make optional the fields which are not required for a user to be valid, to avoid giving special meaning to some values like "".

(* user.ml *)
type t =
  { id : id option
  ; password : Password.t option
  ; email : Email.t
  ; name : String255.t
  ; company : String255.t option
  ; url : String255.t option
  }
(* No creators for id: always comes from DB. *)
and type id = private int

We now have a record which is still entirely readable (i.e. String255.t can still be coerced into a string), but for which field values must necessarily be created by validator functions. See my post about how I create such wrapper types.

More Constraints, More Helper Functions

Let’s make things more interesting:

  • make creation and modification easier with create and update functions;
  • require that a URL be only supplied when there’s a company name;
  • enforce constraints at compile-time anywhere possible.
module User : sig
  type t =
    { id : id option
    ; password : Password.t option
    ; email : Email.t
    ; name : String255.t
    ; company : company option
    }
  and id = private int
  and company =
    { name : String255.t
    ; url : String255.t option
    }

  val create
    :  ?password:string
    -> ?company_name:string
    -> ?company_url:string
    -> email:string
    -> name:string
	-> unit
    -> (t, string) result

  val update
    :  ?password:string
    -> ?email:string
    -> ?name:string
    -> ?company_name:string
    -> ?company_url:string
    -> t
    -> (t, string) result
end

We now enforce all our new constraints; any user created outside of our module is still guaranteed to be valid. Functions create and update offer handy shortcuts to all the relevant fields’ type-specific constructors.

Routine operations like changing a user’s name and e-mail address become simpler, hiding validation boilerplate:

(* By hand *)
let updated_user =
  { old_user with
    name = String255.of_string_exn new_name
  ; email = Email.of_string_exn new_email }
in

(* Using update *)
let updated_user = User.update_exn ~name:new_name ~email:new_email old_user in

Database I/O And Serialization

I need to use User in client applications without a database back-end, and thus without query building code nor dependency on database drivers. I would obtain instances of User.t from some network exchange of serialized information. (JSON, Google Protocol Buffers, Apache Thrift, Bin_prot, etc.) My current plan is to use ppx_deriving_yojson on both the client side (native and Js_of_ocaml) and server side, and to only worry about the faster binary formats if/when I’ll see a measurable bottleneck with JSON.

For server-side things like database I/O, I can create a second module which extends the general one with the additional functionality. For example, User_db could be an extension of User including all its types and functions. Our client library would alias User as itself while our server library would alias User_db to the same name, so that as much code as possible remains portable between programs on the client and server sides.

(* user.mli *)
type t = { ... } [@@deriving yojson]
and id = private int [@@deriving yojson]
val id_of_int_unsafe : manually_validated_id:int -> id
val create ...
val update ...

(* user.ml *)
type t = { ... } [@@deriving yojson]
and id = int [@@deriving yojson]
let id_of_int_unsafe ~manually_validated_id:i = i
let create ...
let update ...

(* user_db.mli *)
include module type of User
val get_from_db : int -> t

(* user_db.ml *)
include User
let get_from_db id =
  let id = User.id_of_int_unsafe ~manually_validated_id=id in
  ...

(* client_library.ml *)
module User = User

(* server_library.ml *)
module User = User_db

Notice I had no choice but to introduce a means to create a User.id from outside User, so that extension modules like User_db may create proper User.t with IDs (which User.create can not, by design). Similarly to how some standard libraries designed unsafe functions, I choose to use an explicit and fairly verbose name and labeled argument to make it clear that creating a User.id comes with its load of responsibilities.

Presumably, I will also create a User_rpc module extension of User for use with browsers, with an API equivalent to, or possibly even identical to, User_db making HTTP and/or WebSocket calls in some fashion to get JSON versions of User.t over the wire.

© 2008-2021 Stéphane Lavergne — Please ask permission before copying parts of this site.

Powered by Hugo & Kiss.