Development process

Content:

  1. Introduction
  2. Process - high level
  3. Process - detail
  4. Example
  5. Final remarks

Introduction:

This article is not about development methodology for professional developers. That topic is covered here.

This article is for the beginner and focus on the most basic. How to get from a problem to some working code. Or "how to program" instead of "how to program in XYZ".

So the intended audience are hobby programmers and students in the first couple of years of their software development related studies.

A little programming experience is required. This is for those that do understand the basic building blocks of data structures and control structures, but find it difficult to actually use those to solve a given problem.

The examples will use C#, PHP and SQL so a little knowledge about these will help while reading. But the code is deliberately kept very simple. For two reasons. First the topic is about how to get from problem to code - not how to write advanced code. Second even in real world simple solutions are actually better than advanced solutions because they are easier to maintain.

The process covered include:

Process - high level:

We will start by going through everything at the super high level.

Understanding the problem:

First thing is to understand what the application should do.

The key concepts are:

Scope
A high level outline of what the application should do and what the application should not do
Requirements
A detailed description of what the application should do that can be used for comparison with the final application

In a professional context then this is typical not something the developer will get involved in producing - the developer just get the documents and have to read them and understand them.

In the educational/hobby context then the developer will typical have to start by creating these.

Architecture:

When requirements is understood then one need to decide:

In a professional context then this is typical not something the developer will get involved in producing. There will be dedicated architects (enterprise architects, solution architects, software architects, infarstructure architects etc. - see here for details about what each does) that make the architectural decisions.

In the educational/hobby context then this will typical be all up to the developer.

Choice of technology:

This is choosing the technology used: programming languages, databases etc..

This is strictly speaking a subtopic under architecture, but it is good to first determine the pieces of the architecture and then determine the right technologies for those pieces.

Sometimes the technology is given by the context. A company is doing everything with MS stack - so it has to be Windows, .NET and SQL Server. You have a web hotel that only offers Linux, PHP and MySQL. Etc..

High level design:

This is outlining the structure of the software. That means create documentation that shows how the implementation should be done. It is not repeating the requirements. It is now writing all the code. It is somewhere in between.

Or to rephrase: it is not describing *what* the code does, it is describing *how* the code does it, it is not writing the code.

And note that high level design serve two purposes:

The initial developer and the maintenance developer may very well be the same developer, but experience shows that after a few years then even the initial developer needs documentation, because all thougths when originally writing the code has been long lost.

Implementation:

When the design is done then it is time to actually write the software.

Test:

Test includes:

Process - detail:

Now we will elaborate a bit on the process.

Understanding the problem:

Scope is typical just a some pages of text.

Requirements can be done in many different ways.

Traditional form is a huge document with enumerated requirements that specifies everything from UI to business rules.

Modern form is use cases.

Use cases are basically the scenarios that needs to be supported starting with the all good flow and then listing all the potential error situations.

The overview of all the use cases can be done by creating UML use case diagrams. See here for a brief intro.

Architecture:

I suggest:

  1. Decide on tiers
  2. Decide on protocols used
  3. Decide on layers in software

For an explanation about tiers and layers see here.

Tiers and protocols can be described in UML Deployment model or what I call an onion model - see here for some details.

Layers typical requires some textual description and/or a custom diagram.

This may sound pretty trivial/obvious, but if not properly understood then it almost guarantees a disaster result.

Choice of technology:

The choices include:

The choice needs to consider:

High level design:

Frontend:

Often UI's are designed/documented by screen drawings or mockups.

Backend:

First step is to describe the logic of the application. How does it come from input to output.

Some of the description can be done in text. But experience show that disgrams can help providing the overview.

Possibel diagrams include UML Activity diagrams and UML Sequence diagrams.

Next step is to come up with key classes and methods. This can be documented in UML Class diagrams.

Database:

For a guide to database design see here

Implementation:

Writing:

Test:

For test:

  1. Verify that all unit tests pass and that test coverage is close to 100% (on unit test friendly code)
  2. Go through all use cases and verify that functionality is working
  3. Use a load test tool to verify that application can handle load

Example:

And finally we will walk through an example.

And to illustrate similarities across different architectures/technologies, then the example will be done twice in parallel.

The example will be rivial, but it has to be trivial - otherwise this would be a 500 page article.

Understanding the problem:

The task at hand is to develop a simple order management application.

We will support the following use cases.

Add customer:

  1. Go to "Add customer" screen
  2. Enter name, address, telephone and email
  3. Click "Add"

Problem scenarios include:

Add order:

  1. Go to "Add order" screen
  2. Select customer from list
  3. For each order line:
    1. Select product from list
    2. Enter quantity
    3. Click "Add"
  4. Click "Add"

Problem scenarios include:

Lookup order:

  1. Go to "Lookup order" screen
  2. Enter order id
  3. Click "Find"
  4. View results

Problem scenarios include:

Result will include:

View sales statistics:

  1. Go to "View sales statistics" screen
  2. Click "Get"
  3. View results

No problem scenarios foreseen.

Result will include:

There are no dependencies between use cases:

Use cases

In a real world case there would be more use cases and they would be more complex, but this should be enough to illustrate.

The business side wants a Windows desktop application for easy integration with other Windows applications.

The business side wants a web application to support both Windows, MacOS and tablets.

Architecture:

The architecture is driven by the requirements for target platform.

The business requirements defined that the solution has to be either 2 tier:

or 3 tier:

We will say that the 2 tier solution is good enough.

Given the simple task we say that 2 layers in the desktop ap is sufficent:

So far architecture looks like:

Architecture step 1

The business requirements defined that the solution has to be 3 tier:

Given the simple task we say that 2 layers in the desktop ap is sufficent:

Presentation layer will be split in traditional MVC parts:

See here for a discussion about various ways to look at MVC.

So far architecture looks like:

Architecture step 1

Choice of technology:

The tech stack is not given by the architecture, but the options are definitely narrowed by the architecture.

There are actually very few modern solution for a Windows desktop application.

C++ MFC and VB6 are way old. Nobody uses Java Swing or JavaFX. Delphi is a niche product.

The two obvious solutions are .NET win forms and .NET WPF.

Despite WPF being newer than win forms then let us go for win forms as the simpler solution.

In a Windows and .NET world then SQL Server is a very natural database choice.

So:

There is no need for a message queue/bus in the solution.

Now architecture looks like:

Architecture step 2

The problem is well suited for ORM instead of direct SQL (see here for description of what ORM is) and EF (Entity Framework is the most widely used .NET ORM, so we will use EF.

Two of the most common logging framework for .NET are log4net and NLog. We will pick log4net because of its similarity to the other log4xxx. Due to the 2 tier model we will go for SmtpAppender (error messages emailed to us).

Similarly we will pick NUnit for unit test because its similarity to the other xUnit frameworks.

Now architecture looks like:

Architecture step 3

Given that:

then a low cost and easy to get people with skills are the LAMP stack:

There are many alternatives: ASP.NET MVC, various Java EE frameworks, node.js, Ruby on Rails, various Python frameworks. But LAMP seems like a good fit for the problem.

There are plenty of PHP MVC frameworks (Laravel, Symfony, Cake, Ignite, Zend etc.), but for simplicity we will go for a fully homegrown solution.

If a cache server turn out to be needed then both memcached and Redis will work fine with the LAMP stack.

There is no need for a message queue/bus in the solution.

Now architecture looks like:

Architecture step 2

Several options exist for PHP top use a MySQL database: mysqli extension, PDO extension, an ORM like Doctrine (see here for info about exach).

ORM's are way less used in PHP than in .NET and Java, so despite the problem being ORM friendly then we go for a non-ORM solution to ensure that all PHP developers can maintain the application.

We will choose PDO extension to be less database dependent.

For logging we will go for log4php to stick to the log4xxx family (Monolog was an obvious alternative). Due to the 3 tier model we will go for LoggerAppenderFile (a log file on the server).

For unit test we will use PHPUnit.

Now architecture looks like:

Architecture step 3

High level design:

Frontend:

We will need 5 screens to support the use cases:

The content of the screens are also pretty much given by the use cases.

Screens will look somewhat like:

Main Menu screen
Add Customer screen
Add Order screen
Lookup Order screen
View Sales Statistics screen

Backend:

For frontend the technology choice of .NET Win Forms determine one class per screen (main, add customer, add order, lookup order, view sales stats)..

For backend we want one DAL class (persister) and a data class per entity (customer, product, order, order line).

One sequence diagram per use case:

Sequence diagram use case 1 Sequence diagram use case 2 Sequence diagram use case 3 Sequence diagram use case 4

Class diagram:

Class diagram

For frontend we need:

For backend we want one DAL class (persister) and a data class per entity (customer, product, order, order line).

One sequence diagram per use case:

Sequence diagram use case 1 Sequence diagram use case 2 Sequence diagram use case 3 Sequence diagram use case 4

Class diagram:

Class diagram

Database:

Persisted classes are:

customers

products

orders

orderlines

Customer have attributes:

Product have attributes:

Order have attributes:

Orderline have attributes:

customers
name
address
phone
email

products
name
description
price

orders
orderdate
status

orderlines
quantity

All tables need id fields.

customers
id,PK
name
address
phone
email

products
id,PK
name
description
price

orders
id,PK
orderdate
status

orderlines
id,PK
quantity

Relations:

So we add FK on M sides.

customers
id,PK
name
address
phone
email

products
id,PK
name
description
price

orders
id,PK
customerid,FK->customer
orderdate
status

orderlines
id,PK
orderid,FK->order
productid,FK->product
quantity

We add some data types:

customers
id(INTEGER),PK
name(VARCHAR)
address(VARCHAR)
phone(VARCHAR)
email(VARCHAR)

products
id(INTEGER),PK
name(VARCHAR)
description(VARCHAR)
price(DECIMAL)

orders
id(INTEGER),PK
customerid(INTEGER),FK->customer
orderdate(DATE)
status(VARCHAR)

orderlines
id(INTEGER),PK
orderid(INTEGER),FK->order
productid(INTEGER),FK->product
quantity(INTEGER)

We add index on:

customers
id(INTEGER),PK
name(VARCHAR),IX
address(VARCHAR)
phone(VARCHAR),IX
email(VARCHAR),IX

products
id(INTEGER),PK
name(VARCHAR),IX
description(VARCHAR)
price(DECIMAL)

orders
id(INTEGER),PK
customerid(INTEGER),FK->customer,IX
orderdate(DATE),IX
status(VARCHAR),IX

orderlines
id(INTEGER),PK
orderid(INTEGER),FK->order,IX
productid(INTEGER),FK->product,IX
quantity(INTEGER)

Implementation:

Minimal implementation.

Frontend:

The win forms code will be manual created not created by drop and drag.

MainForm.cs:

using System;
using System.Drawing;
using System.Windows.Forms;

namespace Order.Frontend
{
    public class MainForm : Form
    {
        private Button addcustomer;
        private Button addorder;
        private Button lookuporder;
        private Button viewsalesstats;
        public MainForm()
        {
            SuspendLayout();
            Text = "Main Menu";
            ClientSize = new Size(400, 400);
            addcustomer = new Button();
            addcustomer.Size = new Size(200, 25);
            addcustomer.Text = "Add Customer";
            addcustomer.Location = new Point(100, 100);
            addcustomer.Click += OpenAddCustomer;
            Controls.Add(addcustomer);
            addorder = new Button();
            addorder.Size = new Size(200, 25);
            addorder.Text = "Add Order";
            addorder.Location = new Point(100, 150);
            addorder.Click += OpenAddOrder;
            Controls.Add(addorder);
            lookuporder = new Button();
            lookuporder.Size = new Size(200, 25);
            lookuporder.Text = "Lookup Order";
            lookuporder.Location = new Point(100, 200);
            lookuporder.Click += OpenLookupOrder;
            Controls.Add(lookuporder);
            viewsalesstats = new Button();
            viewsalesstats.Size = new Size(200, 25);
            viewsalesstats.Text = "View Sales Statistics";
            viewsalesstats.Location = new Point(100, 250);
            viewsalesstats.Click += OpenViewSalesStats;
            Controls.Add(viewsalesstats);
            ResumeLayout(false);
        }
        void OpenAddCustomer(object sender, EventArgs e)
        {
            AddCustomerForm f = new AddCustomerForm();
            f.ShowDialog();
        }
        void OpenAddOrder(object sender, EventArgs e)
        {
            AddOrderForm f = new AddOrderForm();
            f.ShowDialog();
        }
        void OpenLookupOrder(object sender, EventArgs e)
        {
            LookupOrderForm f = new LookupOrderForm();
            f.ShowDialog();
        }
        void OpenViewSalesStats(object sender, EventArgs e)
        {
            ViewSalesStatsForm f = new ViewSalesStatsForm();
            f.ShowDialog();
        }
    }
}

AddCustomerForm.cs:

using System;
using System.Drawing;
using System.Windows.Forms;

using log4net;

using Order.Backend;

namespace Order.Frontend
{
    public class AddCustomerForm : Form
    {
        private static readonly ILog log =
            LogManager.GetLogger(typeof(AddCustomerForm));
        private Label namelbl;
        private TextBox name;
        private Label addrlbl;
        private TextBox addr;
        private Label phonelbl;
        private TextBox phone;
        private Label emaillbl;
        private TextBox email;
        private Button add;
        private Label status;
        public AddCustomerForm()
        {
            SuspendLayout();
            Text = "Add Customer";
            ClientSize = new Size(400, 450);
            namelbl = new Label();
            namelbl.Text = "Name:";
            namelbl.Location = new Point(50, 100);
            Controls.Add(namelbl);
            name = new TextBox();
            name.Size = new Size(200, 25);
            name.Location = new Point(150, 100);
            Controls.Add(name);
            addrlbl = new Label();
            addrlbl.Text = "Address:";
            addrlbl.Location = new Point(50, 150);
            Controls.Add(addrlbl);
            addr = new TextBox();
            addr.Size = new Size(200, 25);
            addr.Location = new Point(150, 150);
            Controls.Add(addr);
            phonelbl = new Label();
            phonelbl.Text = "Telephone:";
            phonelbl.Location = new Point(50, 200);
            Controls.Add(phonelbl);
            phone = new TextBox();
            phone.Size = new Size(200, 25);
            phone.Location = new Point(150, 200);
            Controls.Add(phone);
            emaillbl = new Label();
            emaillbl.Text = "Email:";
            emaillbl.Location = new Point(50, 250);
            Controls.Add(emaillbl);
            email = new TextBox();
            email.Size = new Size(200, 25);
            email.Location = new Point(150, 250);
            Controls.Add(email);
            add = new Button();
            add.Size = new Size(100, 25);
            add.Text = "Add";
            add.Location = new Point(50, 300);
            add.Click += AddCustomer;
            Controls.Add(add);
            status = new Label();
            status.Text = "";
            status.Size = new Size(300, 50);
            status.Location = new Point(50, 350);
            Controls.Add(status);
            ResumeLayout(false);
        }
        void AddCustomer(object sender, EventArgs e)
        {
            Persister p = new Persister();
            Customer c = new Customer { Id = p.GetNextCustomerId(),
                                        Name = name.Text,
                                        Address = addr.Text,
                                        Phone = phone.Text,
                                        Email = email.Text };
            try
            {
                p.SaveCustomer(c);
                log.Info("Customer " + c.Id + " saved");
                status.Text = "Customer " + c.Id + " saved";
            }
            catch (Exception ex)
            {
                log.Error("AddCustomer failed with: " + ex.Message);
                status.Text = "Save failed";
            }
        }
    }
}

AddOrderForm.cs:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;

using log4net;

using Order.Backend;

namespace Order.Frontend
{
    internal class PQ
    {
        public int P { get; set; }
        public int Q { get; set; }
    }
    public class AddOrderForm : Form
    {
        private const string DEFAULT_STATUS = "Open";
        private static readonly ILog log =
            LogManager.GetLogger(typeof(AddOrderForm));
        private Label custlbl;
        private ComboBox cust;
        private Button add;
        private Label status;
        private DataGridView lines;
        private Label prodlbl;
        private ComboBox prod;
        private Label qlbl;
        private TextBox q;
        private Button addline;
        private IList<PQ> templines;
        public AddOrderForm()
        {
            Persister p = new Persister();
            templines = new List<PQ>();
            SuspendLayout();
            ClientSize = new Size(500, 550);
            Text = "Add Order";
            custlbl = new Label();
            custlbl.Text = "Customer:";
            custlbl.Location = new Point(50, 50);
            Controls.Add(custlbl);
            cust = new ComboBox();
            cust.DataSource = p.GetCustomers();
            cust.DisplayMember = "Name";
            cust.ValueMember = "Id";
            cust.Location = new Point(150, 50);
            Controls.Add(cust);
            add = new Button();
            add.Text = "Add";
            add.Location = new Point(350, 50);
            add.Click += AddOrder;
            Controls.Add(add);
            status = new Label();
            status.Text = "";
            status.Size = new Size(300, 50);
            status.Location = new Point(50, 100);
            Controls.Add(status);
            lines = new DataGridView();
            lines.Size = new Size(400, 150);
            lines.Location = new Point(50, 150);
            lines.Columns.Add(null, null);
            lines.Columns.Add(null, null);
            lines.Columns.Add(null, null);
            lines.Columns.Add(null, null);
            lines.Rows.Clear();
            lines.ReadOnly = true;
            lines.ScrollBars = ScrollBars.None;
            lines.ColumnHeadersVisible = false;
            lines.RowHeadersVisible = false;
            Controls.Add(lines);
            prodlbl = new Label();
            prodlbl.Text = "Product:";
            prodlbl.Location = new Point(50, 350);
            Controls.Add(prodlbl);
            prod = new ComboBox();
            prod.DataSource = p.GetProducts();
            prod.DisplayMember = "Name";
            prod.Location = new Point(150, 350);
            Controls.Add(prod);
            qlbl = new Label();
            qlbl.Text = "Quantity:";
            qlbl.Location = new Point(50, 400);
            Controls.Add(qlbl);
            q = new TextBox();
            q.Size = new Size(75, 25);
            q.Location = new Point(150, 400);
            Controls.Add(q);
            addline = new Button();
            addline.Text = "Add";
            addline.Location = new Point(50, 450);
            addline.Click += AddOrderLine;
            Controls.Add(addline);
        }
        void AddOrder(object sender, EventArgs e)
        {
            Persister p = new Persister();  
            int nextoid = p.GetNextOrderId();
            Order.Backend.Order o =
                new Order.Backend.Order { Id = nextoid,
                                          CustomerId = (int)cust.SelectedValue,
                                          OrderDate = DateTime.Now.Date,
                                          Status = DEFAULT_STATUS,
                                          Lines = new List<OrderLine>() };
            int nextolid = p.GetNextOrderLineId ();
            o.Lines = new List<OrderLine>();
            for(int i = 0; i < templines.Count; i++)
            {
                OrderLine ol = new OrderLine { Id = nextolid + i,
                                               OrderId = o.Id,
                                               ProductId = templines[i].P,
                                               Quantity = templines[i].Q };
                o.Lines.Add(ol);
            }
            try
            {
                p.SaveOrder(o);
                log.Info("Order " + o.Id + " saved");
                status.Text = "Order " + o.Id + " saved";
            }
            catch (Exception ex)
            {
                log.Error("AddOrder failed with: " + ex.Message);
                status.Text = "Save failed";
            }
        }
        void AddOrderLine(object sender, EventArgs e)
        {
            Product p2 = (Product)prod.SelectedValue;
            int q2;
            if(int.TryParse(q.Text, out q2) && q2 > 0)
            {
                templines.Add(new PQ { P = p2.Id, Q = q2 });
                int ix = lines.Rows.Add();
                lines.Rows[ix].Cells[0].Value = p2.Name;
                lines.Rows[ix].Cells[1].Value = q2;
                lines.Rows[ix].Cells[2].Value = p2.Price;
                lines.Rows[ix].Cells[3].Value = q2 * p2.Price;
            }
            else
            {
                MessageBox.Show("Invalid quantity");
            }
        }
    }
    
}

LookupOrderForm.cs:

using System;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;

using Order.Backend;

namespace Order.Frontend
{
    public class LookupOrderForm : Form
    {
        private Label oidlbl;
        private TextBox oid;
        private Button find;
        private Label status;
        private Label cinfo;
        private Label oinfo;
        private DataGridView lines;
        public LookupOrderForm()
        {
            SuspendLayout();
            ClientSize = new Size(500, 500);
            Text = "Lookup Order";
            oidlbl = new Label();
            oidlbl.Text = "Order id:";
            oidlbl.Location = new Point(50, 50);
            Controls.Add(oidlbl);
            oid = new TextBox();
            oid.Size = new Size(75, 25);
            oid.Location = new Point(150, 50);
            Controls.Add(oid);
            find = new Button();
            find.Text = "Find";
            find.Location = new Point(250, 50);
            find.Click += LookupOrder;
            Controls.Add(find);
            status = new Label();
            status.Text = "";
            status.Size = new Size(300, 50);
            status.Location = new Point(50, 100);
            Controls.Add(status);
            cinfo = new Label();
            cinfo.Size = new Size(300, 50);
            cinfo.Location = new Point(50,150);
            Controls.Add(cinfo);
            oinfo = new Label();
            oinfo.Size = new Size(300, 50);
            oinfo.Location = new Point(50,200);
            Controls.Add(oinfo);
            lines = new DataGridView();
            lines.Size = new Size(400, 150);
            lines.Location = new Point(50, 250);
            lines.ReadOnly = true;
            lines.ScrollBars = ScrollBars.None;
            lines.ColumnHeadersVisible = false;
            lines.RowHeadersVisible = false;
            Controls.Add(lines);
            ResumeLayout(false);
        }
        void LookupOrder(object sender, EventArgs e)
        {
            Persister p = new Persister();
            Order.Backend.Order o = p.LookupOrder(int.Parse(oid.Text));
            if(o != null)
            {
                status.Text = "";
                cinfo.Text = o.Customer.Name + ", " + o.Customer.Address;
                oinfo.Text = o.OrderDate.ToString("yyyy-MM-dd") +
                             " $" + o.TotalPrice + " " + o.Status;
                lines.DataSource =
                    o.Lines.Select(ol => new { Product = ol.Product.Name,
                                               Quantity = ol.Quantity,
                                               Price = ol.Product.Price,
                                               TotalPrice = ol.TotalPrice })
                            .ToList();
            }
            else
            {
                status.Text = "Order id does not exist";
            }
        }
    }
}

ViewSalesStatsForm.cs:

using System;
using System.Drawing;
using System.Windows.Forms;

using Order.Backend;

namespace Order.Frontend
{
    public class ViewSalesStatsForm : Form
    {
        private DataGridView stats;
        public ViewSalesStatsForm()
        {
            Persister p = new Persister();
            SuspendLayout();
            ClientSize = new Size(400, 400);
            Text = "View Sales Statistics";
            stats = new DataGridView();
            stats.DataSource = p.GetSalesStats();
            stats.Size = new Size(300, 300);
            stats.Location = new Point(50, 50);
            stats.ReadOnly = true;
            stats.ScrollBars = ScrollBars.None;
            stats.RowHeadersVisible = false;
            Controls.Add(stats);
            ResumeLayout(false);
        }
    }
}

Simple HTML and PHP with no CSS or JS (except for adding order lines to order).

main.html:

<html>
<head>
<title>Main Menu</title>
</head>
<body>
<h1>Main Menu</h1>
<button onclick="window.location.href='addcustomer_form.php'">Add Customer</button>
<br>
<button onclick="window.location.href='addorder_form.php'">Add Order</button>
<br>
<button onclick="window.location.href='lookuporder.php'">Lookup Order</button>
<br>
<button onclick="window.location.href='viewsalesstats.php'">View Sales Statistics</button>
</body>
</html>

addcustomer_form.php:

<?php
namespace Order\Frontend\Presentation;

?>
<html>
<head>
<title>Add Customer</title>
<style>
form  { display: table;      }
p     { display: table-row;  }
label { display: table-cell; }
input { display: table-cell; }
</style>
</head>
<body>
<h1>Add Customer</h1>
<form method="POST" action="../Control/addcustomer_ctl.php">
<p>
<label for="name">Name:</label>
<input type="text" id="name" name="name">
</p>
<p>
<label for="addr">Address:</label>
<input type="text" id="addr" name="addr">
</p>
<p>
<label for="phone">Telephone:</label>
<input type="text" id="phone" name="phone">
</p>
<p>
<label for="email">Email:</label>
<input type="text" id="email" name="email">
</p>
<p>
<input type="submit" value="Add">
</p>
</form>
<?php
if(isset($_GET['status'])) {
    $status = $_GET['status'];
} else {
    $status = "";
}
echo "<div>$status</div>\r\n";
?>
</body>
</html>

addcustomer_ctl.php:

<?php
namespace Order\Frontend\Control;

use PDOException;

require_once 'Logger.php';

use Logger;

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

use Order\Backend\Persister;
use Order\Backend\Customer;

Logger::configure('/work/order.xml');
$log = Logger::getLogger('Order.Frontend.Control');
$p = new Persister();
$id = $p->GetNextCustomerId();
$name = $_POST['name'];
$addr = $_POST['addr'];
$phone = $_POST['phone'];
$email = $_POST['email'];
$customer = new Customer($id, $name, $addr, $phone, $email);
try {
    $p->SaveCustomer($customer);
    $log->info("Customer $id saved");
    $status = "Customer $id saved";
} catch(PDOException $ex) {
    $log->error('save customer failed with: ' . $ex->getMessage());
    $status = "Save failed";
}
header('location: ../Presentation/addcustomer_form.php?status=' . urlencode($status));
?>

addorder_form.php:

<?php
namespace Order\Frontend\Presentation;

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

?>
<html>
<head>
<title>Add Order</title>
<style>
form  { display: table;      }
p     { display: table-row;  }
label { display: table-cell; }
input { display: table-cell; }
</style>
<script type="text/javascript" src="/jquery/1.12.4/jquery.min.js"></script>
<script>
// Note on order line add functionality:
//   * order line form has
//     - a product select box p that provide id/name/price
//     - a quantity input field q
//     - an add button that calls add_line JavaScript function
//   * add_line JavaScript function:
//     - adds a row to order lines table with name, quantity, price and total price
//     - adds +productid/quantity to the hidden field lines in the order form
function add_line() {
    q2 = parseInt($("#q").val());
    if(q2 > 0) {
        parts = $("#p").val().split("/");
        $("#t").append("<tr>" +
                       "<td>" + parts[1] + "</td>" +
                       "<td>" + $("#q").val() + "</td>" +
                       "<td>" + parts[2] + "</td>" +
                       "<td>" + ($("#q").val() * parts[2]).toFixed(2) + "</td>" +
                       "</tr>");
        $("#lines").val($("#lines").val() + "+" + parts[0] + "/" + $("#q").val());
    } else {
        alert("Invalid quantity");
    }
}
</script>
</head>
<body>
<h1>Add Order</h1>
<?php
use Order\Backend\Persister;

$p = new Persister();
echo "<form method='POST' action='../Control/addorder_ctl.php'>\r\n";
echo "<p>\r\n";
echo "<label for='cust'>Customer:</label><select id='cust' name='cust'>\r\n";
$allc = $p->GetCustomers();
foreach($allc as $c) {
    echo sprintf("    <option value='%d'>%s</option>\r\n",
                 $c->id, $c->name);
}
echo "</select>\r\n";
echo "<input type='hidden' id='lines' name='lines'>\r\n";
echo "<input type='submit' value='Add'>\r\n";
echo "<p>\r\n";
echo "</form>\r\n";
if(isset($_GET['status'])) {
    $status = $_GET['status'];
} else {
    $status = "";
}
echo "<div>$status</div>\r\n";
echo "<table id='t' border='1'>\r\n";
echo "</table>\r\n";
echo "<form>\r\n";
echo "<p>\r\n";
echo "<label for='p'>Product:</label>\r\n";
echo "<select id='p'>\r\n";
$allp = $p->GetProducts();
foreach($allp as $p) {
    echo sprintf("    <option value='%d/%s/%.2f'>%s</option>\r\n",
                 $p->id, $p->name, $p->price, $p->name);
}
echo "</select>\r\n";
echo "</p>\r\n";
echo "<p>\r\n";
echo "<label for='q'>Quantity:</label>\r\n";
echo "<input type='text' id='q'>\r\n";
echo "</p>\r\n";
echo "<p>\r\n";
echo "<button type='button' onclick='add_line()'>Add</button>\r\n";
echo "</p>\r\n";
echo "</form>\r\n";
?>
</body>
</html>

addorder_ctl.php:

<?php
namespace Order\Frontend\Control;

use PDOException;

require_once 'Logger.php';

use Logger;

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

define('INITIAL_STATUS', 'Open');
date_default_timezone_set('EST');

use Order\Backend\Persister;
use Order\Backend\Customer;
use Order\Backend\Order;
use Order\Backend\OrderLine;
use Order\Backend\Product;

Logger::configure('/work/order.xml');
$log = Logger::getLogger('Order.Frontend.Control');
$p = new Persister();
$cid = $_POST['cust'];
$lines = $_POST['lines'];
$oid = $p->GetNextOrderId();
$c = new Customer($cid, '', '', '', '');
$o = new Order($oid, $c, date('Y-m-d'), INITIAL_STATUS);
$olid =  $p->GetNextOrderLineId();
$line = explode('+', $lines);
for($i = 1; $i < count($line); $i++) {
    $parts = explode('/', $line[$i]);
    $prod = new Product($parts[0], '', '', 0.00);
    $ol = new OrderLine($olid, $prod, $parts[1]);
    $o->lines[] = $ol;
    $olid++;
}
try {
    $p->SaveOrder($o);
    $log->info("Order $oid saved");
    $status = "Order $oid saved";
} catch(PDOException $ex) {
    $log->error('save order failed with: ' . $ex->getMessage());
    $status = "Save failed";
}
header('location: ../Presentation/addorder_form.php?status=' . urlencode($status));
?>

lookuporder.php:

<?php
namespace Order\Frontend\Presentation;

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

?>
<html>
<head>
<title>Lookup Order</title>
</head>
<body>
<h1>Lookup Order</h1>
<form method="POST" action="lookuporder.php">
Order id: <input type="text" name="id">
<input type="submit" value="Find">
</form>
<?php
use Order\Backend\Persister;

if($_SERVER['REQUEST_METHOD'] === 'POST') {
    $id = (int)$_POST['id'];
    $p = new Persister();
    $o = $p->LookupOrder($id);
    if($o != null) {
        echo sprintf("<div>%s, %s</div>\r\n",
                     $o->customer->name, $o->customer->address);
        echo sprintf("<div>%s $%.2f %s</div>\r\n",
                     $o->orderdate, $o->getTotalPrice(), $o->status);
        echo "<table border='1'>\r\n";
        foreach($o->lines as $ol) {
            echo "<tr>\r\n";
            echo sprintf("<td>%s</td>\r\n",
                         $ol->product->name);
            echo sprintf("<td>%d</td>\r\n",
                         $ol->quantity);
            echo sprintf("<td>$%.2f</td>\r\n",
                         $ol->product->price);
            echo sprintf("<td>$%.2f</td>\r\n",
                         $ol->quantity * $ol->product->price);
            echo "</tr>\r\n";
        }
    } else {
        echo "<div>Order id does not exist</div>\r\n";
    }
    echo "</table>\r\n";
}
?>
</body>
</html>

viewsalesstats.php:

<?php
namespace Order\Frontend\Presentation;

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});
?>

<html>
<head>
<title>View Sales Statistics</title>
</head>
<body>
<h1>View Sales Statistics</h1>
<table border='1'>
<?php
use Order\Backend\Persister;

$p = new Persister();
foreach($p->GetSalesStats() as $stats) {
    echo "<tr>\r\n";
    echo sprintf("<td>%s</td>\r\n", $stats->name);
    echo sprintf("<td>%d</td>\r\n", $stats->norders);
    echo sprintf("<td>$%.2f</td>\r\n", $stats->totsale);
    echo "</tr>\r\n";
}
?>
</table>
</body>
</html>

Backend:

Code first approach to EF.

DataModel.cs:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;

namespace Order.Backend
{
    [Table("customers")]
    public class Customer
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Column("id")]
        public int Id { get; set; }
        [Column("name")]
        public string Name { get; set; }
        [Column("address")]
        public string Address { get; set; }
        [Column("phone")]
        public string Phone { get; set; }
        [Column("email")]
        public string Email { get; set; }
    }
    [Table("products")]
    public class Product
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Column("id")]
        public int Id { get; set; }
        [Column("name")]
        public string Name { get; set; }
        [Column("description")]
        public string Description { get; set; }
        [Column("price")]
        public decimal Price { get; set; }
    }
    [Table("orders")]
    public class Order
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Column("id")]
        public int Id { get; set; }
        [Column("customerid")]
        public int CustomerId { get; set; }
        [Column("orderdate")]
        public DateTime OrderDate { get; set; }
        [Column("status")]
        public string Status { get; set; }
        // to be loaded via Include
        public Customer Customer { get; set; }
        public IList<OrderLine> Lines { get; set; }
        [NotMapped]
        public decimal TotalPrice
        {
            get
            {
                return Lines.Select(ol => ol.TotalPrice).Sum();
            }
        }
    }
    [Table("orderlines")]
    public class OrderLine
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Column("id")]
        public int Id { get; set; }
        [Column("orderid")]
        public int OrderId { get; set; }
        [Column("productid")]
        public int ProductId { get; set; }
        [Column("quantity")]
        public int Quantity { get; set; }
        // to be loaded via Include
        public Product Product { get; set; }
        [NotMapped]
        public decimal TotalPrice
        {
            get
            {
                return Quantity * Product.Price;
            }
        }
    }
}

Persister.cs:

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Linq;

namespace Order.Backend
{
    public class MyDbContext : DbContext
    {
        public MyDbContext() : base("devproc")
        {
            Database.Log = Console.Write;
        }
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Product> Products { get; set; }
        public DbSet<Order> Orders { get; set; }
        public DbSet<OrderLine> OrderLines { get; set; }
    }
    public class Stats
    {
        public string Name { get; set; }
        public int NOrders { get; set; }
        public decimal TotSale { get; set; }
    }
    public class Persister
    {
        public int GetNextCustomerId()
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.Customers.Select(c => c.Id).Max() + 1;
            }
        }
        public int GetNextOrderId()
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.Orders.Select(o => o.Id).Max() + 1;
            }
        }
        public int GetNextOrderLineId()
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.OrderLines.Select(ol => ol.Id).Max() + 1;
            }
        }
        public IList<Customer> GetCustomers()
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.Customers.ToList();
            }
        }
        public IList<Product> GetProducts()
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.Products.ToList();
            }
        }
        public void SaveCustomer(Customer c)
        {
            using(MyDbContext db = new MyDbContext())
            {
                db.Customers.Add(c);
                db.SaveChanges();
            }
        }
        public void SaveOrder(Order o)
        {
            using(MyDbContext db = new MyDbContext())
            {
                db.Orders.Add(o);
                db.SaveChanges();
            }
        }
        public Order LookupOrder(int orderid) 
        {
            using(MyDbContext db = new MyDbContext())
            {
                return db.Orders.Include(o => o.Customer)
                                .Include(o => o.Lines)
                                .Include(o => o.Lines
                                .Select(ol => ol.Product))
                                .Where(o => o.Id == orderid)
                                .FirstOrDefault();
            }
        }
        public IList<Stats> GetSalesStats()
        {
            using(MyDbContext db = new MyDbContext())
            {
                // this query is not ORM and LINQ friendly
                //  so it is done in SQL instead
                IDbConnection con = db.Database.Connection;
                con.Open();
                IDbCommand q = con.CreateCommand();
                q.Connection = con;
                q.CommandText =
                    "SELECT customers.name AS name," +
                    "       COUNT(DISTINCT orders.id) AS norders," +
                    "       SUM(orderlines.quantity*products.price) AS totsale " +
                    "FROM ((customers LEFT JOIN orders " +
                    "       ON customers.id = orders.customerid) " +
                    "     JOIN orderlines " +
                    "       ON orders.id = orderlines.orderid) " +
                    "     JOIN products " +
                    "       ON orderlines.productid = products.id " +
                    "GROUP BY customers.name";
                IDataReader rdr = q.ExecuteReader();
                IList<Stats> result = new List<Stats>();
                while(rdr.Read())
                {
                    result.Add(new Stats { Name = (string)rdr["name"],
                                           NOrders = (int)rdr["norders"],
                                           TotSale = (decimal)rdr["totsale"] });
                }
                return result;
            }
        }
    }
}

Program.cs:

using System;
using System.Windows.Forms;

using log4net.Config;

using Order.Frontend;

namespace Order
{
    public class Program
    {
        public static void Main(string[] args)
        {
            XmlConfigurator.Configure();
            Application.Run(new MainForm());
        }
    }
}

app.config:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
        <section
name="entityFramework"
type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework"
requirePermission="false" />
        <section
name="log4net"
type="log4net.Config.Log4NetConfigurationSectionHandler,log4net"/>
    </configSections>
    <connectionStrings>
        <add
name="devproc"
connectionString="Server=ARNEPC4\SQLEXPRESS;Integrated Security=true;Database=devproc"
providerName="System.Data.SqlClient" />
    </connectionStrings>
    <entityFramework>
        <defaultConnectionFactory
type="System.Data.Entity.Infrastructure.SqlConnectionFactory, EntityFramework"/>
        <providers>
            <provider
invariantName="System.Data.SqlClient"
type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer"/>
        </providers>
    </entityFramework>
    <log4net>
        <appender name="mailer" type="log4net.Appender.SmtpAppender,log4net">
            <threshold value="ERROR"/>
            <smtphost value="192.168.0.10"/>
            <port value="25"/>
            <!--
            <enablessl value="true"/>
            <authentication value="Basic"/>
            <username value="SMTP server usename"/>
            <password value="SMTP server password"/>
            -->
            <from value="arne@arne1.vajhoej.dk"/>
            <to value="arne@arne1.vajhoej.dk"/>
            <subject value="Order application log message"/>
            <layout type="log4net.Layout.PatternLayout,log4net">
                <param name="ConversionPattern" value="%-30c %d %-5p %m%n"/>
            </layout>
            <lossy value="true" />
            <evaluator type="log4net.Core.LevelEvaluator">
                <threshold value="DEBUG"/>
            </evaluator>
        </appender>
        <logger name="Order">
            <level value="ERROR"/>
            <appender-ref ref="mailer"/>
        </logger>
    </log4net>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0" />
    </startup>
</configuration>

Trivial PDO code.

Customer.php:

<?php
namespace Order\Backend;

class Customer {
    public $id;
    public $name;
    public $address;
    public $phone;
    public $email;
    public function __construct($id, $name, $address, $phone, $email) {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phone = $phone;
        $this->email = $email;
    }
}

?>

Product.php:

<?php
namespace Order\Backend;

class Product {
    public $id;
    public $name;
    public $description;
    public $price;
    public function __construct($id, $name, $description, $price) {
        $this->id = $id;
        $this->name = $name;
        $this->description = $description;
        $this->price = $price;
    }
}

?>

Order.php:

<?php
namespace Order\Backend;

class Order {
    public $id;
    public $customer;
    public $orderdate;
    public $status;
    public $lines;
    public function __construct($id, $customer, $orderdate, $status) {
        $this->id = $id;
        $this->customer = $customer;
        $this->orderdate = $orderdate;
        $this->status = $status;
        $this->lines = array();
    }
    function getTotalPrice() {
        $res = 0.00;
        foreach($this->lines as $ol) {
            $res += $ol->getTotalPrice();
        }
        return $res;
    }
}

?>

OrderLine.php:

<?php
namespace Order\Backend;

class OrderLine {
    public $id;
    public $product;
    public $quantity;
    public function __construct($id, $product, $quantity) {
        $this->id = $id;
        $this->product = $product;
        $this->quantity = $quantity;
    }
    function getTotalPrice() {
        return $this->quantity * $this->product->price;
    }
}

?>

Persister.php:

<?php
namespace Order\Backend;

use PDO;

require 'dbconfig.php';

class Persister {
    private $con;
    public function __construct() {
        $this->con = new PDO(CONSTR, UN, PW);
        $this->con->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->con->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    }
    function GetNextCustomerId() {
        $stmt = $this->con->prepare('SELECT MAX(id)+1 AS nextid FROM customers');
        $stmt->execute(array());
        if($row = $stmt->fetch()) {
            return $row['nextid'];
        } else {
            return 1;
        }
    }
    function GetNextOrderId() {
        $stmt = $this->con->prepare('SELECT MAX(id)+1 AS nextid FROM orders');
        $stmt->execute(array());
        if($row = $stmt->fetch()) {
            return $row['nextid'];
        } else {
            return 1;
        }
    }
    function GetNextOrderLineId() {
        $stmt = $this->con->prepare('SELECT MAX(id)+1 AS nextid FROM orderlines');
        $stmt->execute(array());
        if($row = $stmt->fetch()) {
            return $row['nextid'];
        } else {
            return 1;
        }
    }
    function GetCustomers() {
        $result = array();
        $stmt = $this->con->prepare('SELECT id,name,address,phone,email FROM customers');
        $stmt->execute(array());
        while($row = $stmt->fetch()) {
            $result[] = new Customer($row['id'],
                                     $row['name'],
                                     $row['address'],
                                     $row['phone'],
                                     $row['email']);
        }
        return $result;
    }
    function GetProducts() {
        $result = array();
        $stmt = $this->con->prepare('SELECT id,name,description,price FROM products');
        $stmt->execute(array());
        while($row = $stmt->fetch()) {
            $result[] = new Product($row['id'],
                                    $row['name'],
                                    $row['description'],
                                    $row['price']);
        }
        return $result;
    }
    function SaveCustomer($customer) {
        $stmt = $this->con->prepare('INSERT INTO customers VALUES(:id,:name,:address,:phone,:email)');
        $stmt->execute(array(':id' => $customer->id,
                                ':name' => $customer->name,
                                ':address' => $customer->address,
                                ':phone' => $customer->phone,
                                ':email' => $customer->email));
    }
    function SaveOrder($order) {
        $ostmt = $this->con->prepare('INSERT INTO orders VALUES(:id,:customerid,:orderdate,:status)');
        $ostmt->execute(array(':id' => $order->id,
                                 ':customerid' => $order->customer->id,
                                 ':orderdate' => $order->orderdate,
                                 ':status' => $order->status));
        $olstmt = $this->con->prepare('INSERT INTO orderlines VALUES(:id,:orderid,:productid,:quantity)');
        foreach($order->lines as $ol) {
            $olstmt->execute(array(':id' => $ol->id,
                                      ':orderid' => $order->id,
                                      ':productid' => $ol->product->id,
                                      ':quantity' => $ol->quantity));
        }
    }
    function LookupOrder($orderid) {
        $stmt = $this->con->prepare('SELECT orders.id AS oid,orders.orderdate AS odate,' .
                                    '       orders.status AS ostat,' .
                                    '       customers.id AS cid,' .
                                    '       customers.name AS cname,' .
                                    '       customers.address AS caddr,' .
                                    '       customers.phone AS cphone,' .
                                    '       customers.email AS cemail,' .
                                    '       orderlines.id AS olid,' .
                                    '       orderlines.quantity AS olq,' .
                                    '       products.id AS pid,' .
                                    '       products.name AS pname,' .
                                    '       products.description AS pdescr,' .
                                    '       products.price AS pprice ' .
                                    'FROM ((orders JOIN customers ON orders.customerid = customers.id) ' .
                                    '     JOIN orderlines ON orders.id = orderlines.orderid) ' .
                                    '     JOIN products ON orderlines.productid = products.id ' .
                                    'WHERE orders.id = :orderid');
        $stmt->execute(array(':orderid' => $orderid));
        $first = true;
        $order = null;
        while($row = $stmt->fetch()) {
            if($first) {
                $customer = new Customer($row['cid'],
                                         $row['cname'],
                                         $row['caddr'],
                                         $row['cphone'],
                                         $row['cemail']);
                $order = new Order($row['oid'],
                                   $customer,
                                   $row['odate'],
                                   $row['ostat']);
                $first = false;
            }
            $product = new Product($row['pid'],
                                   $row['pname'],
                                   $row['pdescr'],
                                   $row['pprice']); 
            $orderline = new OrderLine($row['olid'],
                                       $product,
                                       $row['olq']);
            $order->lines[] = $orderline;
        }
        return $order;
    }
    function GetSalesStats() {
        $res = array();
        $stmt = $this->con->prepare('SELECT customers.name AS name,' .
                                    '       COUNT(DISTINCT orders.id) AS norders,' .
                                    '       SUM(orderlines.quantity*products.price) AS totsale ' .
                                    'FROM ((customers LEFT JOIN orders ON customers.id = orders.customerid) ' .
                                    '     JOIN orderlines ON orders.id = orderlines.orderid) ' .
                                    '     JOIN products ON orderlines.productid = products.id ' .
                                    'GROUP BY customers.name');
        $stmt->execute(array());
        while($row = $stmt->fetch()) {
            $res[] = new Stats($row['name'], $row['norders'], $row['totsale']); 
        }
        return $res;
    }
}

?>

order.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration xmlns="http://logging.apache.org/log4php/">
     <appender name="logfile" class="LoggerAppenderFile">
        <param name="threshold" value="error"/>
        <param name="file" value="C:/work/order.log"/>
        <layout class="LoggerLayoutPattern">
            <param name="conversionPattern" value="%-30c %d %-5p %m%n"/>
        </layout>
    </appender>
    <logger name="Order">
        <level value="error"/>
        <appender_ref ref="logfile"/>
    </logger>
</configuration>

Unit test:

We will only test the backend classes.

OrderTest.cs:

using System;
using System.Collections.Generic;
using System.Linq;

using NUnit.Framework;

using Order.Backend;

namespace Order.Test
{
    [TestFixture]
    public class TestCustomer
    {
        [Test]
        public void TestProperties()
        {
            Customer c = new Customer { Id = 123,
                                        Name = "ABC",
                                        Address = "123 Somewhere Rd",
                                        Phone = "123456789",
                                        Email = "boss@abc.com" };
            Assert.AreEqual(123, c.Id, "id");
            Assert.AreEqual("ABC", c.Name, "name");
            Assert.AreEqual("123 Somewhere Rd", c.Address, "address");
            Assert.AreEqual("123456789", c.Phone, "phone");
            Assert.AreEqual("boss@abc.com", c.Email, "email");
        }
    }
    [TestFixture]
    public class TestProduct
    {
        [Test]
        public void TestProperties()
        {
            Product p = new Product { Id = 123,
                                      Name = "Something",
                                      Description = "Just something",
                                      Price = 123.45m };
            Assert.AreEqual(123, p.Id, "id");
            Assert.AreEqual("Something", p.Name, "name");
            Assert.AreEqual("Just something", p.Description, "description");
            Assert.AreEqual(123.45m, p.Price, "price");
        }
    }
    [TestFixture]
    public class TestOrder
    {
        [Test]
        public void TestProperties()
        {
            Order.Backend.Order o =
                new Order.Backend.Order { Id = 123,
                                          CustomerId = 321,
                                          OrderDate = DateTime.Now.Date,
                                          Status = "????",
                                          Lines = new List<OrderLine>() };
            o.Lines.Add(new OrderLine { Id = 12301,
                                        OrderId = 123,
                                        ProductId = 12,
                                        Quantity = 1 });
            o.Lines.Add(new OrderLine { Id = 12302,
                                        OrderId = 123,
                                        ProductId = 21,
                                        Quantity = 2 });
            Assert.AreEqual(123, o.Id, "id");
            Assert.AreEqual(321, o.CustomerId, "customer id");
            Assert.AreEqual(DateTime.Now.Date, o.OrderDate, "order date");
            Assert.AreEqual("????", o.Status, "status");
            Assert.AreEqual(12301, o.Lines[0].Id, "orderline 0 id");
            Assert.AreEqual(123, o.Lines[0].OrderId, "orderline 0 order id");
            Assert.AreEqual(12, o.Lines[0].ProductId, "orderline 0 product id");
            Assert.AreEqual(1, o.Lines[0].Quantity, "orderline 0 quantity");
            Assert.AreEqual(12302, o.Lines[1].Id, "orderline 0 id");
            Assert.AreEqual(123, o.Lines[1].OrderId, "orderline 0 order id");
            Assert.AreEqual(21, o.Lines[1].ProductId, "orderline 0 product id");
            Assert.AreEqual(2, o.Lines[1].Quantity, "orderline 0 quantity");
        }
        [Test]
        public void TestTotalPrice()
        {
            Order.Backend.Order o =
                new Order.Backend.Order { Id = 123,
                                          CustomerId = 321,
                                          OrderDate = DateTime.Now.Date,
                                          Status = "????",
                                          Lines = new List<OrderLine>() };
            o.Lines.Add(new OrderLine { Id = 12301,
                                        OrderId = 123,
                                        ProductId = 12,
                                        Quantity = 1 });
            o.Lines.Add(new OrderLine { Id = 12302,
                                        OrderId = 123,
                                        ProductId = 21,
                                        Quantity = 2 });
            // hack to setup what Persister would load:
            o.Lines[0].Product = new Product { Id = 12, Price = 10.00m };
            o.Lines[1].Product = new Product { Id = 32, Price = 20.00m };
            Assert.AreEqual(10.00m, o.Lines[0].TotalPrice, "order line 0 total price");
            Assert.AreEqual(40.00m, o.Lines[1].TotalPrice, "order line 1 total price");
            Assert.AreEqual(50.00m, o.TotalPrice, "total price");
        }
    }
    [TestFixture]
    public class TestPersister
    {
        [Test]
        public void TestGetNextCustomerId()
        {
            Persister p = new Persister();
            int id = p.GetNextCustomerId();
            Assert.AreEqual(3, id, "next customer id");
        }
        [Test]
        public void TestGetNextOrderId()
        {
            Persister p = new Persister();
            int id = p.GetNextOrderId();
            Assert.AreEqual(4, id, "next order id");
        }
        [Test]
        public void TestGetNextOPrderLineId()
        {
            Persister p = new Persister();
            int id = p.GetNextOrderLineId();
            Assert.AreEqual(5, id, "next order line id");
        }
        [Test]
        public void TestGetCustomers()
        {
            Persister p = new Persister();
            IList<Customer> lst = p.GetCustomers();
            Assert.AreEqual(2, lst.Count, "number customers");
        }
        [Test]
        public void TestGetProducts()
        {
            Persister p = new Persister();
            IList<Product> lst = p.GetProducts();
            Assert.AreEqual(4, lst.Count, "number products");
        }
        [Test]
        public void TestSaveCustomer()
        {
            Persister p = new Persister();
            Customer c = new Customer { Id = 999,
                                        Name = "XXX",
                                        Address = "X X X",
                                        Phone = "123456789",
                                        Email = "boss@xxx.com" };
            p.SaveCustomer(c);
            Customer c2 = p.GetCustomers().Last();
            Assert.AreEqual(999, c2.Id, "customer id");
            // cleanup:
            using(MyDbContext db = new MyDbContext())
            {
                db.Customers.Remove(db.Customers.FirstOrDefault(rc => rc.Id == 999));
                db.SaveChanges();
            }
        }
        [Test]
        public void TestSaveOrder()
        {
            Persister p = new Persister();
            Order.Backend.Order o =
                new Order.Backend.Order { Id = 999,
                                          CustomerId = 1,
                                          OrderDate = DateTime.Now.Date,
                                          Status = "Open",
                                          Lines = new List<OrderLine>() };
            o.Lines.Add(new OrderLine { Id = 999,
                                        OrderId = 999,
                                        ProductId = 1,
                                        Quantity = 1 });
            p.SaveOrder(o);
            Order.Backend.Order o2 = p.LookupOrder(999);
            Assert.AreEqual(999, o2.Id, "order id");
            Assert.AreEqual(1, o2.Lines.Count, "number order lines");
            Assert.AreEqual(999, o2.Lines[0].Id, "order line id");
            // cleanup:
            using(MyDbContext db = new MyDbContext())
            {
                db.OrderLines.Remove(db.OrderLines.FirstOrDefault(rol => rol.Id == 999));
                db.Orders.Remove(db.Orders.FirstOrDefault(ro => ro.Id == 999));
                db.SaveChanges();
            }
        }
        [Test]
        public void TestLookupOrder()
        {
            Persister p = new Persister();
            Order.Backend.Order o = p.LookupOrder(3);
            Assert.AreEqual(3, o.Id, "order id");
            Assert.AreEqual(2, o.Lines.Count, "number order lines");
        }
        [Test]
        public void TestGetSalesStats()
        {
            Persister p = new Persister();
            IList<Stats> stats = p.GetSalesStats();
            Assert.AreEqual(2, stats.Count, "number stats");
            Assert.Greater(stats[0].NOrders, 0, "customer 1 number orders");
            Assert.Greater(stats[1].NOrders, 0, "customer 2 number orders");
        }
    }
}

TestCustomer.php:

<?php
spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});
    
use PHPUnit\Framework\TestCase;

use Order\Backend\Customer;

class TestCustomer extends TestCase {
    public function testProperties() {
        $c = new Customer(123, 'ABC', '123 Somewhere Rd', '123456789', 'boss@ab$c.com');
        $this->assertEquals(123, $c->id, 'id');
        $this->assertEquals('ABC', $c->name, 'name');
        $this->assertEquals('123 Somewhere Rd', $c->address, 'address');
        $this->assertEquals('123456789', $c->phone, 'phone');
        $this->assertEquals('boss@ab$c->com', $c->email, 'email');
    }
}

?>

TestProduct.php:

<?php
spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});
    
use PHPUnit\Framework\TestCase;

use Order\Backend\Product;

class TestProduct extends TestCase {
    public function testProperties() {
        $p = new Product(123, 'Something', 'Just something',123.45);
        $this->assertEquals(123, $p->id, 'id');
        $this->assertEquals('Something', $p->name, 'name');
        $this->assertEquals('Just something', $p->description, 'description');
        $this->assertEquals(123.45, $p->price, 'price');
    }
}

?>

TestOrder.php:

<?php
spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});
    
use PHPUnit\Framework\TestCase;

use Order\Backend\Order;
use Order\Backend\OrderLine;
use Order\Backend\Customer;
use Order\Backend\Product;

class TestOrder extends TestCase {
    protected function setUp() {
        date_default_timezone_set('EST');
    }
    public function testProperties() {
        $o = new Order(123,
                       new Customer(321, 'ABC', '123 Somewhere Rd', '123456789',
                       'boss@abc.com'),
                       date('Y-m-d'),
                       '????');
        $o->lines[] = new OrderLine(12301,
                                       new Product(12, 'Something', 'Just something', 10.00),
                                       1 );
        $o->lines[] = new OrderLine(12302,
                                       new Product(21, 'Something', 'Just something', 20.00),
                                       2 );
        $this->assertEquals(123, $o->id, 'id');
        $this->assertEquals(321, $o->customer->id, 'customer id');
        $this->assertEquals(date('Y-m-d'), $o->orderdate, 'order date');
        $this->assertEquals('????', $o->status, 'status');
        $this->assertEquals(12301, $o->lines[0]->id, 'orderline 0 id');
        $this->assertEquals(12, $o->lines[0]->product->id, 'orderline 0 product id');
        $this->assertEquals(1, $o->lines[0]->quantity, 'orderline 0 quantity');
        $this->assertEquals(12302, $o->lines[1]->id, 'orderline 0 id');
        $this->assertEquals(21, $o->lines[1]->product->id, 'orderline 0 product id');
        $this->assertEquals(2, $o->lines[1]->quantity, 'orderline 0 quantity');
    }
    public function testTotalPrice() {
        $o = new Order(123,
                       new Customer(321, 'ABC', '123 Somewhere Rd', '123456789',
                       'boss@abc.com'),
                       date('Y-m-d'),
                       '????');
        $o->lines[] = new OrderLine(12301,
                                       new Product(12, 'Something', 'Just something', 10.00),
                                       1 );
        $o->lines[] = new OrderLine(12302,
                                       new Product(21, 'Something', 'Just something', 20.00),
                                       2 );
        $this->assertEquals(10.00, $o->lines[0]->getTotalPrice(), 'order line 0 total price');
        $this->assertEquals(40.00, $o->lines[1]->getTotalPrice(), 'order line 1 total price');
        $this->assertEquals(50.00, $o->getTotalPrice(), 'total price');
    }
}

?>

TestPersister.php:

<?php
spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});
    
use PHPUnit\Framework\TestCase;

use Order\Backend\Persister;
use Order\Backend\Customer;
use Order\Backend\Order;
use Order\Backend\OrderLine;
use Order\Backend\Product;

class TestPersister extends TestCase {
    protected function setUp() {
        date_default_timezone_set('EST');
    }
    public function testGetNextCustomerId() {
        $p = new Persister();
        $id = $p->GetNextCustomerId();
        $this->assertEquals(3, $id, 'next customer id');
    }
    public function testGetNextOrderId() {
        $p = new Persister();
        $id = $p->GetNextOrderId();
        $this->assertEquals(4, $id, 'next order id');
    }
    public function testGetNextOrderLineId() {
        $p = new Persister();
        $id = $p->GetNextOrderLineId();
        $this->assertEquals(5, $id, 'next orderline id');
    }
    public function testGetCustomers() {
        $p = new Persister();
        $lst = $p->getCustomers();
        $this->assertEquals(2, count($lst), "number customers");
    }
    public function testGetProducts() {
        $p = new Persister();
        $lst = $p->getProducts();
        $this->assertEquals(4, count($lst), "number products");
    }
    public function testSaveCustomer() {
        $p = new Persister();
        $c = new Customer(999, 'XXX', 'X X X', '123456789', 'boss@xxx.com');
        $p->SaveCustomer($c);
        $temp = $p->GetCustomers();
        $c2 = $temp[count($temp) - 1];
        $this->assertEquals(999, $c2->id, "customer id");
        // cleanup:
        $con = new PDO('mysql:host=localhost;dbname=devproc', 'root', '');
        $con->query('DELETE FROM customers WHERE id = 999');
    }
    public function testSaveOrder() {
        $p = new Persister();
        $o = new Order(999,
                       new Customer(1, 'ABC', '123 Somewhere Rd', '123456789', 'boss@ab$c->com'),
                       date('Y-m-d'),
                       'Open');
        $o->lines[] = new OrderLine(999,
                                       new Product(1, 'Something', 'Just something', 10.00),
                                       1);
        $p->SaveOrder($o);
        $o2 = $p->LookupOrder(999);
        $this->assertEquals(999, $o2->id, "order id");
        $this->assertEquals(1, count($o2->lines), "number order lines");
        $this->assertEquals(999, $o2->lines[0]->id, "order line id");
        // cleanup:
        $con = new PDO('mysql:host=localhost;dbname=devproc', 'root', '');
        $con->query('DELETE FROM orderlines WHERE id = 999');
        $con->query('DELETE FROM orders WHERE id = 999');
    }
    public function testLookupOrder() {
        $p = new Persister();
        $o = $p->lookupOrder(3);
        $this->assertEquals(3, $o->id, "order id");
        $this->assertEquals(2, count($o->lines), "number order lines");
    }
    public function testGetSalesStats() {
        $p = new Persister();
        $stats = $p->getSalesStats();
        $this->assertEquals(2, count($stats), "number stats");
        $this->assertGreaterThan(0, $stats[0]->norders, "customer 1 number orders");
        $this->assertGreaterThan(0, $stats[1]->norders, "customer 2 number orders");
    }
}
?>

Database:

DDL:

CREATE TABLE customers (
    id INTEGER NOT NULL,
    name VARCHAR(25),
    address VARCHAR(50),
    phone VARCHAR(25),
    email VARCHAR(50),
    PRIMARY KEY(id)
);
CREATE TABLE products (
    id INTEGER NOT NULL,
    name VARCHAR(25),
    description VARCHAR(250),
    price NUMERIC(12,2),
    PRIMARY KEY(id)
);
CREATE TABLE orders (
    id INTEGER NOT NULL,
    customerid INTEGER NOT NULL,
    orderdate DATE,
    status VARCHAR(10),
    PRIMARY KEY(id)
);
CREATE TABLE orderlines (
    id INTEGER NOT NULL,
    orderid INTEGER NOT NULL,
    productid INTEGER NOT NULL,
    quantity INTEGER,
    PRIMARY KEY(id)
);
CREATE INDEX customers_name_ix ON customers(name);
CREATE INDEX customers_phone_ix ON customers(phone);
CREATE INDEX customers_email_ix ON customers(email);
CREATE INDEX products_name_ix ON products(name);
CREATE INDEX orders_customerid_ix ON orders(customerid);
CREATE INDEX orders_orderdate_ix ON orders(orderdate);
CREATE INDEX orders_status_ix ON orders(status);
CREATE INDEX orderlines_orderid_ix ON orderlines(orderid);
CREATE INDEX orderlines_productid_ix ON orderlines(productid);

DML (test data):

INSERT INTO customers VALUES(1, 'Big biz', '1 Imperial Avenue', '123-456-7890', 'purchasedept@bigbiz.com');
INSERT INTO customers VALUES(2, 'Small biz', '99 Nowhere Road', '987-654-3210', 'boss@smallbiz.com');
INSERT INTO products VALUES(1, 'Practical UML', 'An intro to UML diagrams', 45.00);
INSERT INTO products VALUES(2, 'Practical C#', 'An intro to C# programming', 35.00);
INSERT INTO products VALUES(3, 'Practical PHP', 'An intro to PHP programming', 35.00);
INSERT INTO products VALUES(4, 'Practical SQL', 'An intro to SQL and databases', 30.00);
INSERT INTO orders VALUES(1, 1, '2022-02-11', 'Delivered');
INSERT INTO orders VALUES(2, 1, '2022-02-11', 'Open');
INSERT INTO orders VALUES(3, 2, '2022-02-11', 'Delivered');
INSERT INTO orderlines VALUES(1, 1, 1, 5);
INSERT INTO orderlines VALUES(2, 2, 4, 5);
INSERT INTO orderlines VALUES(3, 3, 2, 1);
INSERT INTO orderlines VALUES(4, 3, 3, 1);

Test:

Screenshots:

screenshot
screenshot
screenshot
screenshot
screenshot
screenshot
screenshot
screenshot
screenshot
screenshot

Final remarks:

The example above is very primitive. The functionality is primitive. The UI is primitive. The business logic is primitive (not existing).

More logging and more unit tests are definitely needed.

And a real world application would have a lot more use cases.

But hopefully it is sufficient to illustrate how we got from requirements to design to working code.

Question: would an experienced developer do all that for this problem? Answer: I am pretty sure that everything would not be put on paper for such a simple problem. But the experienced developer would make some equivalent thougths before writing the code.

The same experienced developer would probably also disagree with some of my design choices and would have coded the implementation differently. And that is fair enough - there are different solutions than mine that work.

Article history:

Version Date Description
1.0 February 22nd 2022 Initial version

Other articles:

See list of all articles here

Comments:

Please send comments to Arne Vajhøj