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 intWe 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
createandupdatefunctions; - 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
endWe 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 inDatabase 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_dbNotice 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.