diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -2,14 +2,17 @@
 
 Generate Graphviz documents from a Haskell representation.
 
+## Getting Started
+
 ```
-cabal configure
-cabal build
+cabal sandbox init
+cabal configure --enable-tests
+cabal install --only-dependencies --enable-tests
 ```
 
 ## Usage
 
-![Legend](https://rawgit.com/owickstrom/dataflow/master/example/legend.svg)
+![Legend](https://rawgit.com/owickstrom/dataflow/master/examples/legend.svg)
 
 The objects supported by DataFlow is:
 
@@ -29,13 +32,14 @@
 module Main where
 
 import DataFlow.Core
+import DataFlow.Graphviz.Renderer
 import DataFlow.DFD
 
 main :: IO ()
-main = printDfd $
-  Diagram "My Diagram" [
+main = putStr $ renderGraphviz $ asDFD  $
+  Diagram (Just "Webapp") [
     TrustBoundary "browser" "Browser" [
-      Function "webapp" "Webapp"
+      Function "client" "Client"
     ],
     TrustBoundary "aws" "Amazon AWS" [
       Function "server" "Web Server",
@@ -43,11 +47,11 @@
     ],
     InputOutput "analytics" "Google Analytics",
 
-    Flow "webapp" "server" "Request /" "",
+    Flow "client" "server" "Request /" "",
     Flow "server" "logs" "Log" "User IP",
-    Flow "server" "webapp" "Response" "User Profile",
+    Flow "server" "client" "Response" "User Profile",
 
-    Flow "webapp" "analytics" "Log" "Page Navigation"
+    Flow "client" "analytics" "Log" "Page Navigation"
   ]
 ```
 
@@ -59,13 +63,12 @@
 
 That should generate something like the following.
 
-![Example Output](https://rawgit.com/owickstrom/dataflow/master/example/example.svg)
+![Example Output](https://rawgit.com/owickstrom/dataflow/master/examples/webapp.svg)
 
 ## Building the Examples
 
 ```bash
-cabal install
-make -C example
+make -C examples
 ```
 
 ## Release
diff --git a/dataflow.cabal b/dataflow.cabal
--- a/dataflow.cabal
+++ b/dataflow.cabal
@@ -1,5 +1,5 @@
 name:                dataflow
-version:             0.4.2.0
+version:             0.5.0.0
 synopsis:            Generate Graphviz documents from a Haskell representation.
 description:         Outputs .dot files that can be processed by the dot
                      command. Currently it only supports the DFD output format
@@ -17,10 +17,11 @@
 build-type:          Simple
 extra-source-files:  README.md,
                      LICENSE,
-                     example/example.hs,
-                     example/example.svg,
-                     example/legend.hs,
-                     example/legend.svg
+                     examples/webapp.hs,
+                     examples/webapp.svg,
+                     examples/legend.hs,
+                     examples/legend.svg,
+                     examples/Makefile
 cabal-version:       >=1.10
 
 source-repository head
@@ -30,6 +31,8 @@
 library
   exposed-modules:
     DataFlow.Core,
+    DataFlow.Graphviz,
+    DataFlow.Graphviz.Renderer,
     DataFlow.DFD
   build-depends:
     base >=4 && <4.8,
@@ -37,3 +40,15 @@
     containers >= 0.4
   hs-source-dirs:      src
   default-language:    Haskell2010
+
+test-suite spec
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  default-language: Haskell98
+  hs-source-dirs: test
+  build-depends:
+    base,
+    HUnit,
+    hspec == 2.*,
+    dataflow
+  ghc-options: -Wall
diff --git a/example/example.hs b/example/example.hs
deleted file mode 100644
--- a/example/example.hs
+++ /dev/null
@@ -1,23 +0,0 @@
-module Main where
-
-import DataFlow.Core
-import DataFlow.DFD
-
-main :: IO ()
-main = printDfd $
-  Diagram "My Diagram" [
-    TrustBoundary "browser" "Browser" [
-      Function "webapp" "Webapp"
-    ],
-    TrustBoundary "aws" "Amazon AWS" [
-      Function "server" "Web Server",
-      Database "logs" "Logs"
-    ],
-    InputOutput "analytics" "Google Analytics",
-
-    Flow "webapp" "server" "Request /" "",
-    Flow "server" "logs" "Log" "User IP",
-    Flow "server" "webapp" "Response" "User Profile",
-
-    Flow "webapp" "analytics" "Log" "Page Navigation"
-  ]
diff --git a/example/example.svg b/example/example.svg
deleted file mode 100644
--- a/example/example.svg
+++ /dev/null
@@ -1,70 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
- "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<!-- Generated by graphviz version 2.38.0 (20140413.2041)
- -->
-<!-- Title: My Diagram Pages: 1 -->
-<svg width="502pt" height="382pt"
- viewBox="0.00 0.00 502.14 382.14" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 378.145)">
-<title>My Diagram</title>
-<polygon fill="white" stroke="none" points="-4,4 -4,-378.145 498.137,-378.145 498.137,4 -4,4"/>
-<text text-anchor="start" x="193.172" y="-355.145" font-family="sans-serif" font-weight="bold" font-size="20.00">My Diagram</text>
-<g id="clust2" class="cluster"><title>cluster_aws</title>
-<polygon fill="none" stroke="#4d4d4d" stroke-dasharray="5,2" points="221.346,-190.145 221.346,-338.145 486.137,-338.145 486.137,-190.145 221.346,-190.145"/>
-<text text-anchor="start" x="323.078" y="-326.145" font-family="sans-serif" font-size="10.00" fill="#4d4d4d">Amazon AWS</text>
-</g>
-<g id="clust1" class="cluster"><title>cluster_browser</title>
-<polygon fill="none" stroke="#4d4d4d" stroke-dasharray="5,2" points="8,-110.145 8,-230.145 109.78,-230.145 109.78,-110.145 8,-110.145"/>
-<text text-anchor="start" x="40.5524" y="-218.145" font-family="sans-serif" font-size="10.00" fill="#4d4d4d">Browser</text>
-</g>
-<!-- webapp -->
-<g id="node1" class="node"><title>webapp</title>
-<ellipse fill="none" stroke="black" cx="58.8898" cy="-161.145" rx="42.7799" ry="42.7799"/>
-<text text-anchor="start" x="32.9406" y="-157.945" font-family="sans-serif" font-weight="bold" font-size="14.00">Webapp</text>
-</g>
-<!-- server -->
-<g id="node2" class="node"><title>server</title>
-<ellipse fill="none" stroke="black" cx="285.985" cy="-255.145" rx="56.7776" ry="56.7776"/>
-<text text-anchor="start" x="249.153" y="-251.945" font-family="sans-serif" font-weight="bold" font-size="14.00">Web Server</text>
-</g>
-<!-- webapp&#45;&gt;server -->
-<g id="edge1" class="edge"><title>webapp&#45;&gt;server</title>
-<path fill="none" stroke="black" d="M98.8414,-177.411C133.323,-191.81 184.216,-213.063 224.076,-229.709"/>
-<polygon fill="black" stroke="black" points="222.86,-232.994 233.436,-233.618 225.557,-226.534 222.86,-232.994"/>
-<text text-anchor="start" x="129.128" y="-225.545" font-family="sans-serif" font-weight="bold" font-size="12.00">(1) Request /</text>
-</g>
-<!-- analytics -->
-<g id="node4" class="node"><title>analytics</title>
-<polygon fill="none" stroke="black" stroke-width="2" points="346.274,-120.434 225.695,-120.434 225.695,0.144879 346.274,0.144879 346.274,-120.434"/>
-<text text-anchor="start" x="233.84" y="-56.9445" font-family="sans-serif" font-weight="bold" font-size="14.00">Google Analytics</text>
-</g>
-<!-- webapp&#45;&gt;analytics -->
-<g id="edge4" class="edge"><title>webapp&#45;&gt;analytics</title>
-<path fill="none" stroke="black" d="M84.2655,-126.418C94.0477,-114.842 106.228,-102.928 119.78,-95.1445 148.674,-78.5496 184.474,-69.7911 215.353,-65.1818"/>
-<polygon fill="black" stroke="black" points="216.188,-68.6001 225.619,-63.7724 215.236,-61.6651 216.188,-68.6001"/>
-<text text-anchor="start" x="144.799" y="-110.545" font-family="sans-serif" font-weight="bold" font-size="12.00">(4) Log</text>
-<text text-anchor="start" x="119.78" y="-98.5445" font-family="sans-serif" font-size="12.00">Page Navigation</text>
-</g>
-<!-- server&#45;&gt;webapp -->
-<g id="edge3" class="edge"><title>server&#45;&gt;webapp</title>
-<path fill="none" stroke="black" d="M256.434,-206.502C240.39,-181.104 221.004,-153.919 207.84,-147.145 177.542,-131.553 139.128,-135.775 109.036,-143.516"/>
-<polygon fill="black" stroke="black" points="107.874,-140.206 99.1738,-146.253 109.746,-146.952 107.874,-140.206"/>
-<text text-anchor="start" x="127.792" y="-162.545" font-family="sans-serif" font-weight="bold" font-size="12.00">(3) Response</text>
-<text text-anchor="start" x="132.468" y="-150.545" font-family="sans-serif" font-size="12.00">User Profile</text>
-</g>
-<!-- logs -->
-<g id="node3" class="node"><title>logs</title>
-<text text-anchor="start" x="434.958" y="-266.945" font-family="sans-serif" font-weight="bold" font-size="14.00">Logs</text>
-<polyline fill="none" stroke="black" points="430.137,-258.145 470.137,-258.145 "/>
-<polyline fill="none" stroke="black" points="470.137,-282.145 430.137,-282.145 "/>
-</g>
-<!-- server&#45;&gt;logs -->
-<g id="edge2" class="edge"><title>server&#45;&gt;logs</title>
-<path fill="none" stroke="black" d="M342.788,-260.307C365.568,-262.414 391.305,-264.795 411.754,-266.686"/>
-<polygon fill="black" stroke="black" points="411.642,-270.191 421.922,-267.627 412.287,-263.221 411.642,-270.191"/>
-<text text-anchor="start" x="365.122" y="-280.545" font-family="sans-serif" font-weight="bold" font-size="12.00">(2) Log</text>
-<text text-anchor="start" x="364.129" y="-268.545" font-family="sans-serif" font-size="12.00">User IP</text>
-</g>
-</g>
-</svg>
diff --git a/example/legend.hs b/example/legend.hs
deleted file mode 100644
--- a/example/legend.hs
+++ /dev/null
@@ -1,17 +0,0 @@
-module Main where
-
-import DataFlow.Core
-import DataFlow.DFD
-
-main :: IO ()
-main = printDfd $
-  Diagram "Legend" [
-    TrustBoundary "trust" "TrustBoundary" [
-      Function "function" "Function",
-      Database "database" "Database",
-      InputOutput "io" "InputOutput"
-    ],
-    Flow "function" "io" "Flow" "",
-    Flow "function" "database" "Flow" ""
-  ]
-
diff --git a/example/legend.svg b/example/legend.svg
deleted file mode 100644
--- a/example/legend.svg
+++ /dev/null
@@ -1,46 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
- "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<!-- Generated by graphviz version 2.38.0 (20140413.2041)
- -->
-<!-- Title: Legend Pages: 1 -->
-<svg width="297pt" height="284pt"
- viewBox="0.00 0.00 296.55 284.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 280)">
-<title>Legend</title>
-<polygon fill="white" stroke="none" points="-4,4 -4,-280 292.552,-280 292.552,4 -4,4"/>
-<text text-anchor="start" x="110.907" y="-257" font-family="sans-serif" font-weight="bold" font-size="20.00">Legend</text>
-<g id="clust1" class="cluster"><title>cluster_trust</title>
-<polygon fill="none" stroke="#4d4d4d" stroke-dasharray="5,2" points="8,-8 8,-240 280.552,-240 280.552,-8 8,-8"/>
-<text text-anchor="start" x="111.666" y="-228" font-family="sans-serif" font-size="10.00" fill="#4d4d4d">TrustBoundary</text>
-</g>
-<!-- function -->
-<g id="node1" class="node"><title>function</title>
-<ellipse fill="none" stroke="black" cx="60.0255" cy="-129" rx="44.051" ry="44.051"/>
-<text text-anchor="start" x="33.1773" y="-125.8" font-family="sans-serif" font-weight="bold" font-size="14.00">Function</text>
-</g>
-<!-- database -->
-<g id="node2" class="node"><title>database</title>
-<text text-anchor="start" x="198.504" y="-30.8" font-family="sans-serif" font-weight="bold" font-size="14.00">Database</text>
-<polyline fill="none" stroke="black" points="193.469,-22 262.469,-22 "/>
-<polyline fill="none" stroke="black" points="262.469,-46 193.469,-46 "/>
-</g>
-<!-- function&#45;&gt;database -->
-<g id="edge2" class="edge"><title>function&#45;&gt;database</title>
-<path fill="none" stroke="black" d="M98.6807,-107.443C124.884,-92.4419 159.787,-72.4609 186.571,-57.1275"/>
-<polygon fill="black" stroke="black" points="188.379,-60.1253 195.319,-52.1195 184.901,-54.0504 188.379,-60.1253"/>
-<text text-anchor="start" x="122.051" y="-96.4" font-family="sans-serif" font-weight="bold" font-size="12.00">(2) Flow</text>
-</g>
-<!-- io -->
-<g id="node3" class="node"><title>io</title>
-<polygon fill="none" stroke="black" stroke-width="2" points="272.635,-213.665 183.304,-213.665 183.304,-124.335 272.635,-124.335 272.635,-213.665"/>
-<text text-anchor="start" x="191.387" y="-165.8" font-family="sans-serif" font-weight="bold" font-size="14.00">InputOutput</text>
-</g>
-<!-- function&#45;&gt;io -->
-<g id="edge1" class="edge"><title>function&#45;&gt;io</title>
-<path fill="none" stroke="black" d="M102.9,-139.094C124.311,-144.255 150.608,-150.593 173.472,-156.105"/>
-<polygon fill="black" stroke="black" points="172.828,-159.55 183.37,-158.49 174.468,-152.745 172.828,-159.55"/>
-<text text-anchor="start" x="122.051" y="-157.4" font-family="sans-serif" font-weight="bold" font-size="12.00">(1) Flow</text>
-</g>
-</g>
-</svg>
diff --git a/examples/Makefile b/examples/Makefile
new file mode 100644
--- /dev/null
+++ b/examples/Makefile
@@ -0,0 +1,13 @@
+SOURCES=$(shell find *.hs)
+TARGETS=$(SOURCES:%.hs=%.svg)
+
+%.svg: %.hs
+	@echo "$< -> $@"
+	@runhaskell -i../src $< | dot -Tsvg > $@
+
+svg: $(TARGETS)
+
+clean:
+	rm -f $(TARGETS)
+
+all: svg
diff --git a/examples/legend.hs b/examples/legend.hs
new file mode 100644
--- /dev/null
+++ b/examples/legend.hs
@@ -0,0 +1,19 @@
+module Main where
+
+import DataFlow.Core
+import DataFlow.Graphviz.Renderer
+import DataFlow.DFD
+
+main :: IO ()
+main = putStr $ renderGraphviz $ asDFD $
+  Diagram Nothing [
+    TrustBoundary "trust" "TrustBoundary" [
+      Function "function" "Function",
+      Database "database" "Database",
+      InputOutput "io" "InputOutput"
+    ],
+    Flow "function" "io" "Flow" "",
+    Flow "io" "function" "Flow" "(backwards)",
+    Flow "function" "database" "Flow" ""
+  ]
+
diff --git a/examples/legend.svg b/examples/legend.svg
new file mode 100644
--- /dev/null
+++ b/examples/legend.svg
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.38.0 (20140413.2041)
+ -->
+<!-- Title: Untitled Pages: 1 -->
+<svg width="370pt" height="300pt"
+ viewBox="0.00 0.00 370.00 300.48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 296.481)">
+<title>Untitled</title>
+<polygon fill="white" stroke="none" points="-4,4 -4,-296.481 366,-296.481 366,4 -4,4"/>
+<g id="clust1" class="cluster"><title>cluster_trust</title>
+<polygon fill="none" stroke="#595959" stroke-dasharray="5,2" points="8,-8 8,-284.481 354,-284.481 354,-8 8,-8"/>
+<text text-anchor="start" x="148.393" y="-273.481" font-family="Arial" font-style="italic" font-size="10.00" fill="#595959">TrustBoundary</text>
+</g>
+<!-- function -->
+<g id="node1" class="node"><title>function</title>
+<ellipse fill="none" stroke="black" cx="190" cy="-210.074" rx="46.8172" ry="46.8172"/>
+<text text-anchor="start" x="163.152" y="-207.923" font-family="Arial" font-weight="bold" font-size="14.00">Function</text>
+</g>
+<!-- database -->
+<g id="node2" class="node"><title>database</title>
+<text text-anchor="start" x="58.5347" y="-58.3825" font-family="Arial" font-weight="bold" font-size="14.00">Database</text>
+<polyline fill="none" stroke="black" points="53.5,-47.5825 122.5,-47.5825 "/>
+<polyline fill="none" stroke="black" points="122.5,-73.5825 53.5,-73.5825 "/>
+</g>
+<!-- function&#45;&gt;database -->
+<g id="edge3" class="edge"><title>function&#45;&gt;database</title>
+<path fill="none" stroke="black" d="M156.061,-177.629C146.51,-167.788 136.58,-156.519 128.664,-145.165 116.243,-127.349 105.643,-105.165 98.3825,-88.129"/>
+<polygon fill="black" stroke="black" points="101.565,-86.6655 94.5049,-78.7656 95.0974,-89.3438 101.565,-86.6655"/>
+<text text-anchor="start" x="129" y="-132.464" font-family="Arial" font-weight="bold" font-size="12.00">Flow</text>
+</g>
+<!-- io -->
+<g id="node3" class="node"><title>io</title>
+<polygon fill="none" stroke="black" stroke-width="2" points="313.665,-105.248 224.335,-105.248 224.335,-15.9173 313.665,-15.9173 313.665,-105.248"/>
+<text text-anchor="start" x="232.417" y="-58.4318" font-family="Arial" font-weight="bold" font-size="14.00">InputOutput</text>
+</g>
+<!-- function&#45;&gt;io -->
+<g id="edge1" class="edge"><title>function&#45;&gt;io</title>
+<path fill="none" stroke="black" d="M173.056,-166.198C170.005,-151.853 169.686,-136.207 176.664,-123.165 185.251,-107.116 200.073,-94.5268 215.359,-85.0284"/>
+<polygon fill="black" stroke="black" points="217.483,-87.8421 224.369,-79.7904 213.965,-81.7904 217.483,-87.8421"/>
+<text text-anchor="start" x="177" y="-132.464" font-family="Arial" font-weight="bold" font-size="12.00">Flow</text>
+</g>
+<!-- function&#45;&gt;io -->
+<g id="edge2" class="edge"><title>function&#45;&gt;io</title>
+<path fill="none" stroke="black" d="M216.6,-159.412C226.002,-141.858 236.5,-122.259 245.616,-105.241"/>
+<polygon fill="black" stroke="black" points="213.433,-157.913 211.796,-168.381 219.603,-161.218 213.433,-157.913"/>
+<text text-anchor="start" x="248.561" y="-136.565" font-family="Arial" font-weight="bold" font-size="12.00">Flow</text>
+<text text-anchor="start" x="234" y="-126.165" font-family="Arial" font-size="10.00">(backwards)</text>
+</g>
+</g>
+</svg>
diff --git a/examples/webapp.hs b/examples/webapp.hs
new file mode 100644
--- /dev/null
+++ b/examples/webapp.hs
@@ -0,0 +1,24 @@
+module Main where
+
+import DataFlow.Core
+import DataFlow.Graphviz.Renderer
+import DataFlow.DFD
+
+main :: IO ()
+main = putStr $ renderGraphviz $ asDFD  $
+  Diagram (Just "Webapp") [
+    TrustBoundary "browser" "Browser" [
+      Function "client" "Client"
+    ],
+    TrustBoundary "aws" "Amazon AWS" [
+      Function "server" "Web Server",
+      Database "logs" "Logs"
+    ],
+    InputOutput "analytics" "Google<br/>Analytics",
+
+    Flow "client" "server" "Request /" "",
+    Flow "server" "logs" "Log" "User IP",
+    Flow "server" "client" "Response" "User Profile",
+
+    Flow "client" "analytics" "Log" "Page Navigation"
+  ]
diff --git a/examples/webapp.svg b/examples/webapp.svg
new file mode 100644
--- /dev/null
+++ b/examples/webapp.svg
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.38.0 (20140413.2041)
+ -->
+<!-- Title: Webapp Pages: 1 -->
+<svg width="333pt" height="450pt"
+ viewBox="0.00 0.00 333.01 450.40" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 446.4)">
+<title>Webapp</title>
+<polygon fill="white" stroke="none" points="-4,4 -4,-446.4 329.01,-446.4 329.01,4 -4,4"/>
+<text text-anchor="start" x="125.44" y="-423.4" font-family="Arial" font-weight="bold" font-size="20.00">Webapp</text>
+<g id="clust1" class="cluster"><title>cluster_browser</title>
+<polygon fill="none" stroke="#595959" stroke-dasharray="5,2" points="115,-298.185 115,-403.402 201,-403.402 201,-298.185 115,-298.185"/>
+<text text-anchor="start" x="139.663" y="-392.402" font-family="Arial" font-style="italic" font-size="10.00" fill="#595959">Browser</text>
+</g>
+<g id="clust2" class="cluster"><title>cluster_aws</title>
+<polygon fill="none" stroke="#595959" stroke-dasharray="5,2" points="8,-8 8,-258.185 144,-258.185 144,-8 8,-8"/>
+<text text-anchor="start" x="45.3408" y="-247.185" font-family="Arial" font-style="italic" font-size="10.00" fill="#595959">Amazon AWS</text>
+</g>
+<!-- client -->
+<g id="node1" class="node"><title>client</title>
+<ellipse fill="none" stroke="black" cx="158" cy="-341.044" rx="34.7182" ry="34.7182"/>
+<text text-anchor="start" x="140.104" y="-338.893" font-family="Arial" font-weight="bold" font-size="14.00">Client</text>
+</g>
+<!-- server -->
+<g id="node2" class="node"><title>server</title>
+<ellipse fill="none" stroke="black" cx="76" cy="-170.343" rx="60.1865" ry="60.1865"/>
+<text text-anchor="start" x="39.1714" y="-168.192" font-family="Arial" font-weight="bold" font-size="14.00">Web Server</text>
+</g>
+<!-- client&#45;&gt;server -->
+<g id="edge1" class="edge"><title>client&#45;&gt;server</title>
+<path fill="none" stroke="black" d="M123.059,-338.065C89.5097,-334.089 40.5091,-322.371 16.6367,-288.185 2.08217,-267.343 11.5388,-242.438 26.8205,-220.986"/>
+<polygon fill="black" stroke="black" points="29.6634,-223.03 32.9246,-212.949 24.0889,-218.796 29.6634,-223.03"/>
+<text text-anchor="start" x="17" y="-275.484" font-family="Arial" font-weight="bold" font-size="12.00">Request /</text>
+</g>
+<!-- client&#45;&gt;server -->
+<g id="edge3" class="edge"><title>client&#45;&gt;server</title>
+<path fill="none" stroke="black" d="M138.654,-300.242C127.716,-277.74 113.896,-249.306 102.019,-224.873"/>
+<polygon fill="black" stroke="black" points="135.631,-302.03 143.15,-309.493 141.927,-298.97 135.631,-302.03"/>
+<text text-anchor="start" x="132" y="-279.585" font-family="Arial" font-weight="bold" font-size="12.00">Response</text>
+<text text-anchor="start" x="132.899" y="-269.185" font-family="Arial" font-size="10.00">User Profile</text>
+</g>
+<!-- analytics -->
+<g id="node4" class="node"><title>analytics</title>
+<polygon fill="none" stroke="black" stroke-width="2" points="325.021,-206.364 252.979,-206.364 252.979,-134.323 325.021,-134.323 325.021,-206.364"/>
+<text text-anchor="start" x="266.428" y="-174.143" font-family="Arial" font-weight="bold" font-size="14.00">Google</text>
+<text text-anchor="start" x="260.99" y="-160.143" font-family="Arial" font-weight="bold" font-size="14.00">Analytics</text>
+</g>
+<!-- client&#45;&gt;analytics -->
+<g id="edge4" class="edge"><title>client&#45;&gt;analytics</title>
+<path fill="none" stroke="black" d="M181.196,-314.881C188.653,-306.533 196.835,-297.095 204,-288.185 223.027,-264.524 243.063,-237.118 258.9,-214.798"/>
+<polygon fill="black" stroke="black" points="261.843,-216.698 264.754,-206.51 256.126,-212.659 261.843,-216.698"/>
+<text text-anchor="start" x="248.681" y="-279.585" font-family="Arial" font-weight="bold" font-size="12.00">Log</text>
+<text text-anchor="start" x="222" y="-269.185" font-family="Arial" font-size="10.00">Page Navigation</text>
+</g>
+<!-- logs -->
+<g id="node3" class="node"><title>logs</title>
+<text text-anchor="start" x="72.8208" y="-31.8" font-family="Arial" font-weight="bold" font-size="14.00">Logs</text>
+<polyline fill="none" stroke="black" points="68,-21 108,-21 "/>
+<polyline fill="none" stroke="black" points="108,-47 68,-47 "/>
+</g>
+<!-- server&#45;&gt;logs -->
+<g id="edge2" class="edge"><title>server&#45;&gt;logs</title>
+<path fill="none" stroke="black" d="M81.2983,-110.027C82.7846,-93.3881 84.3291,-76.0972 85.5602,-62.3138"/>
+<polygon fill="black" stroke="black" points="89.0697,-62.3631 86.4733,-52.0913 82.0974,-61.7403 89.0697,-62.3631"/>
+<text text-anchor="start" x="91.6592" y="-83.4" font-family="Arial" font-weight="bold" font-size="12.00">Log</text>
+<text text-anchor="start" x="85" y="-73" font-family="Arial" font-size="10.00">User IP</text>
+</g>
+</g>
+</svg>
diff --git a/src/DataFlow/Core.hs b/src/DataFlow/Core.hs
--- a/src/DataFlow/Core.hs
+++ b/src/DataFlow/Core.hs
@@ -4,30 +4,9 @@
   Operation,
   Description,
   Diagram(..),
-  Object(..),
-  Renderer,
-  evalDiagram,
-  write,
-  writeln,
-  nextStep,
-  indent,
-  dedent,
-  withIndent,
-  blank,
-  label,
-  tag,
-  bold,
-  table,
-  tr,
-  td,
-  Enclosing(..),
-  objectWith,
-  attrs
+  Object(..)
   ) where
 
-import Control.Monad.State
-import Control.Monad.Writer
-
 -- | An identifier corresponding to those in Graphviz.
 type ID = String
 -- | The name of a 'Diagram' or 'Object'.
@@ -38,7 +17,7 @@
 type Description = String
 
 -- | The top level diagram.
-data Diagram = Diagram Name [Object]
+data Diagram = Diagram (Maybe Name) [Object]
 
 -- | An object in a diagram.
 data Object =
@@ -52,112 +31,3 @@
             | Database ID Name
             -- | Describes the flow of data between two objects.
             | Flow ID ID Operation Description deriving (Show, Eq)
-
-type Indent = Int
-type IndentNext = Bool
-type Step = Int
-data RendererState = RendererState Indent IndentNext Step
-
--- | The Renderer represents some output generator that runs on a 'Diagram'.
-type Renderer t = WriterT [String] (State RendererState) t
-
--- | Run the 'Renderer' and get the output as a 'String'.
-evalDiagram :: Renderer () -> String
-evalDiagram g = concat $ evalState (execWriterT g) (RendererState 0 False 0)
-
--- | Write a string to the output (no linefeed).
-write :: String -> Renderer ()
-write s = do
-  (RendererState n indentNext step) <- lift get
-  if indentNext
-    then tell [replicate n ' ' ++ s]
-    else tell [s]
-  put $ RendererState n False step
-
--- | Write a string to the output (with linefeed).
-writeln :: String -> Renderer ()
-writeln s = do
-  write s
-  write "\n"
-  modify $ \(RendererState n _ s') -> RendererState n True s'
-
-incrStep :: Renderer ()
-incrStep = modify $ \(RendererState n indentNext s') -> RendererState n indentNext (s' + 1)
-
--- | Get the next \"step\" number (the order of flow arrows in the diagram).
-nextStep :: Renderer Int
-nextStep = do
-  incrStep
-  (RendererState _ _ s) <- lift get
-  return s
-
--- | Increase indent with 2 spaces.
-indent :: Renderer ()
-indent = modify $ \(RendererState n indentNext s) -> RendererState (n + 2) indentNext s
-
--- | Decrease indent with 2 spaces.
-dedent :: Renderer ()
-dedent = modify $ \(RendererState n indentNext s) -> RendererState (n - 2) indentNext s
-
--- | Indent the output of gen with 2 spaces.
-withIndent :: Renderer () -> Renderer ()
-withIndent gen = do
-  indent
-  gen
-  dedent
-
--- | Write a blank line.
-blank :: Renderer ()
-blank = tell [""]
-
--- | Write a label with the output of gen as its contents.
-label :: Renderer () -> Renderer ()
-label contents = do
-  write "label = <"
-  contents
-  writeln ">;"
-
--- | Write an HTML tag t with the output of gen as its contents.
-tag :: String -> String -> Renderer () -> Renderer ()
-tag t a contents = do
-  write $ "<" ++ t ++ (if null a then "" else " " ++ a) ++ ">"
-  contents
-  write $ "</" ++ t ++ ">"
-
--- | Write a \<b\> tag surrounding the output of another 'Renderer'.
-bold :: Renderer () -> Renderer ()
-bold = tag "b" ""
-
--- | Write a \<table\> tag, with attributes, surrounding the output of
---   another 'Renderer'.
-table :: String -> Renderer () -> Renderer ()
-table = tag "table"
-
--- | Write a \<tr\> tag surrounding the output of another 'Renderer'.
-tr :: Renderer () -> Renderer ()
-tr = tag "tr" ""
-
--- | Write a \<td\> tag surrounding the output of another 'Renderer'.
-td :: Renderer () -> Renderer ()
-td = tag "td" ""
-
--- | The enclosing characters in a block.
-data Enclosing = Brackets | CurlyBrackets
-
--- | Write an object with the given 'Enclosing' characters, 'ID' and 'Renderer' as
---   its contents.
-objectWith :: Enclosing -> ID -> Renderer () -> Renderer ()
-objectWith enc id' attributes =
-  do
-    blank
-    writeln $ id' ++ " " ++ before enc
-    withIndent attributes
-    writeln $ after enc
-  where before Brackets = "["
-        before CurlyBrackets = "{"
-        after Brackets = "]"
-        after CurlyBrackets = "}"
-
--- | Write an attributes declaration for the given 'ID'.
-attrs :: ID -> String -> Renderer ()
-attrs id' = objectWith Brackets id' . writeln
diff --git a/src/DataFlow/DFD.hs b/src/DataFlow/DFD.hs
--- a/src/DataFlow/DFD.hs
+++ b/src/DataFlow/DFD.hs
@@ -1,124 +1,105 @@
 module DataFlow.DFD where
 
-import Control.Monad.Identity
-import Control.Monad.State
-import Data.Map (Map)
-import qualified Data.Map as Map
-import DataFlow.Core
-
-type DFDState v = State (Map (ID, ID) Bool) v
-type DFDRenderer t = DFDState (Renderer t)
-
--- | Type class for types that can be rendered as DFD.
-class RenderDFD t where
-  dfd :: t -> DFDRenderer ()
-
-return' :: t -> DFDRenderer t
-return' v = return . lift . return $ v
-
-exists :: (ID, ID) -> DFDState Bool
-exists k = do
-  m <- get
-  return $ case Map.lookup k m of
-            (Just _) -> True
-            _ -> False
+import Text.Printf
+import qualified DataFlow.Core as C
+import DataFlow.Graphviz
+import DataFlow.Graphviz.EdgeNormalization
 
-register :: (ID, ID) -> DFDState ()
-register k = do
-  m <- get
-  put $ Map.insert k True m
-  return ()
+inQuotes :: String -> String
+inQuotes s = "\"" ++ s ++ "\""
 
-shouldInvert :: (ID, ID) -> DFDState Bool
-shouldInvert k@(i1, i2) = do
-  e <- exists k
-  if e
-    then return False
-    else do
-      ie <- exists (i2, i1)
-      if ie
-        then return True
-        else do
-          register k
-          return False
+inAngleBrackets :: String -> String
+inAngleBrackets s = "<" ++ s ++ ">"
 
-instance RenderDFD Object where
-  dfd (InputOutput id' name) =
-    return $ objectWith Brackets id' $ do
-      writeln "shape = square;"
-      writeln "style = bold;"
-      label $ bold $ write name
+label :: String -> Attr
+label s = Attr (ID "label") (ID $ inAngleBrackets s)
 
-  dfd (TrustBoundary id' name objects) = do
-    renderObjects <- mapM dfd objects
-    return $ do
-      blank
-      writeln $ "subgraph cluster_" ++ id' ++ " {"
-      withIndent $ do
-        blank
-        sequence_ renderObjects
-        writeln "fontsize = 10;"
-        writeln "fontcolor = grey30;"
-        label $ write name
-        writeln "graph[style = dashed, color=grey30];"
-      writeln "}"
+bold :: String -> String
+bold s = "<b>" ++ s ++ "</b>"
 
-  dfd (Function id' name) = return $ objectWith Brackets id' $ do
-    writeln "shape = circle;"
-    label $ bold $ write name
+italic :: String -> String
+italic s = "<i>" ++ s ++ "</i>"
 
-  dfd (Database id' name) = return $ objectWith Brackets id' $ do
-    label $
-      table "sides=\"TB\" cellborder=\"0\"" $
-        tr $
-          td $
-            bold $ write name
-    writeln "shape = none;"
+convertObject :: C.Object -> StmtList
 
-  dfd (Flow i1 i2 operation description)= do
-    back <- shouldInvert (i1, i2)
-    return $ do
-      step <- nextStep
-      blank
-      if back
-        then writeln $ i2 ++ " -> " ++ i1 ++ " ["
-        else writeln $ i1 ++ " -> " ++ i2 ++ " ["
-      withIndent $ do
-        when back $
-          writeln "dir = back;"
-        label $ do
-          bold $ write $ "(" ++ show step ++ ") " ++ operation
-          write "<br/>"
-          write description
-      writeln "]"
+convertObject (C.InputOutput id' name) = [
+    NodeStmt (ID id') [
+      Attr (ID "shape") (ID "square"),
+      Attr (ID "style") (ID "bold"),
+      label $ bold name
+    ]
+  ]
 
-instance RenderDFD Diagram where
-  dfd (Diagram title objects) =
-    do
-      renderObjects <- mapM dfd objects
-      return $ do
-        writeln $ "digraph \"" ++ title ++ "\" {"
-        withIndent $ do
-          attrs "graph" "fontname=\"sans-serif\""
-          attrs "node" "fontname=\"sans-serif\""
-          attrs "edge" "fontname=\"sans-serif\", fontsize=12"
-          blank
+convertObject (C.TrustBoundary id' name objects) =
+  let sgId = (ID $ "cluster_" ++ id')
+      objectStmts = convertObjects objects
+      sgAttrStmt = AttrStmt Graph [
+          Attr (ID "fontsize") (ID "10"),
+          Attr (ID "fontcolor") (ID "grey35"),
+          Attr (ID "style") (ID "dashed"),
+          Attr (ID "color") (ID "grey35"),
+          label $ italic name
+        ]
+      stmts = sgAttrStmt : objectStmts
+  in [SubgraphStmt $ Subgraph sgId stmts]
 
-          writeln "labelloc = \"t\";"
-          label $ bold $ write title
-          writeln "fontsize = 20;"
+convertObject (C.Function id' name) =
+  [
+    NodeStmt (ID id') [
+      Attr (ID "shape") (ID "circle"),
+      label $ bold name
+    ]
+  ]
 
-          writeln "nodesep = 1;"
-          writeln "rankdir = LR;"
+convertObject (C.Database id' name) =
+  [
+    NodeStmt (ID id') [
+      Attr (ID "shape") (ID "none"),
+      label $ printf "<table sides=\"TB\" cellborder=\"0\"><tr><td>%s</td></tr></table>" (bold name)
+    ]
+  ]
 
-          sequence_ renderObjects
+convertObject (C.Flow i1 i2 op desc) =
+  [
+    EdgeStmt (EdgeExpr (IDOperand (NodeID (ID i1) Nothing))
+                       Arrow
+                       (IDOperand (NodeID (ID i2) Nothing))) [
+      label $
+        let d = if null desc then ""
+                             else "<font point-size=\"10\">" ++ desc ++ "</font>"
+        in bold op ++ "<br/>" ++ d
+    ]
+  ]
 
-        writeln "}"
+convertObjects :: [C.Object] -> StmtList
+convertObjects = concatMap convertObject
 
--- | Generates the DFD output as a String.
-evalDfd :: Diagram -> String
-evalDfd d = evalDiagram (evalState (dfd d) Map.empty)
+defaultGraphStmts :: StmtList
+defaultGraphStmts = [
+    AttrStmt Graph [
+      Attr (ID "fontname") (ID "Arial"),
+      Attr (ID "fontsize") (ID "14")
+    ],
+    AttrStmt Node [
+      Attr (ID "fontname") (ID "Arial"),
+      Attr (ID "fontsize") (ID "14")
+    ],
+    AttrStmt Edge [
+      Attr (ID "shape") (ID "none"),
+      Attr (ID "fontname") (ID "Arial"),
+      Attr (ID "fontsize") (ID "12")
+    ],
+    EqualsStmt (ID "labelloc") (ID $ inQuotes "t"),
+    EqualsStmt (ID "fontsize") (ID "20"),
+    EqualsStmt (ID "nodesep") (ID "1"),
+    EqualsStmt (ID "rankdir") (ID "t")
+  ]
 
--- | Prints the DFD output to stdout.
-printDfd :: Diagram -> IO ()
-printDfd = putStr . evalDfd
+asDFD :: C.Diagram -> Graph
+asDFD (C.Diagram (Just name) objects) =
+  normalize $ Digraph (ID $ inQuotes name) $
+    let lbl = EqualsStmt (ID "label") (ID $ inAngleBrackets $ bold name)
+    in lbl : defaultGraphStmts ++ convertObjects objects
+asDFD (C.Diagram Nothing objects) =
+  normalize $ Digraph (ID "Untitled") $
+    defaultGraphStmts ++ convertObjects objects
diff --git a/src/DataFlow/Graphviz.hs b/src/DataFlow/Graphviz.hs
new file mode 100644
--- /dev/null
+++ b/src/DataFlow/Graphviz.hs
@@ -0,0 +1,40 @@
+-- | DataFlow.Graphviz provides a model corresponding to the Graphviz language
+--   described at http://www.graphviz.org/content/dot-language. All features
+--   in the grammar are not supported.
+module DataFlow.Graphviz where
+
+newtype ID = ID String deriving (Eq, Ord)
+
+instance Show ID where
+  show (ID i) = i
+
+data Attr = Attr ID ID deriving (Show, Eq)
+type AttrList = [Attr]
+
+data Compass = N | NE | E | SE | S | SW | W | NW | C deriving (Show, Eq, Ord)
+
+data Port = Port (Maybe ID) Compass deriving (Show, Eq, Ord)
+
+data NodeID = NodeID ID (Maybe Port) deriving (Show, Eq, Ord)
+
+data Subgraph = Subgraph ID StmtList deriving (Show, Eq)
+
+data EdgeOperator = Arrow | Line deriving (Show, Eq)
+
+data EdgeOperand = IDOperand NodeID
+                 | SubgraphOperand Subgraph deriving (Show, Eq)
+
+data EdgeExpr = EdgeExpr EdgeOperand EdgeOperator EdgeOperand deriving (Show, Eq)
+
+data AttrStmtType = Graph | Node | Edge deriving (Show, Eq)
+
+data Stmt = NodeStmt ID AttrList
+          | EdgeStmt EdgeExpr AttrList
+          | AttrStmt AttrStmtType AttrList
+          | EqualsStmt ID ID
+          | SubgraphStmt Subgraph deriving (Show, Eq)
+
+type StmtList = [Stmt]
+
+-- TODO: Support undirected graph
+data Graph = Digraph ID StmtList deriving (Show, Eq)
diff --git a/src/DataFlow/Graphviz/Renderer.hs b/src/DataFlow/Graphviz/Renderer.hs
new file mode 100644
--- /dev/null
+++ b/src/DataFlow/Graphviz/Renderer.hs
@@ -0,0 +1,148 @@
+{-# LANGUAGE FlexibleInstances #-}
+
+module DataFlow.Graphviz.Renderer (
+  renderGraphviz
+  ) where
+
+import Control.Monad.State
+import Control.Monad.Writer
+import Data.Char
+import Text.Printf
+import DataFlow.Graphviz
+
+type Indent = Int
+type IndentNext = Bool
+type Step = Int
+data RendererState = RendererState Indent IndentNext Step
+
+-- | The Renderer represents some output generator that runs on a 'Diagram'.
+type Renderer t = WriterT [String] (State RendererState) t
+
+class Renderable t where
+  render :: t -> Renderer ()
+
+-- | Write a string to the output (no linefeed).
+write :: String -> Renderer ()
+write s = do
+  (RendererState n indentNext step) <- lift get
+  if indentNext
+    then tell [replicate n ' ' ++ s]
+    else tell [s]
+  put $ RendererState n False step
+
+-- | Write a string to the output (with linefeed).
+writeln :: String -> Renderer ()
+writeln s = do
+  write s
+  write "\n"
+  modify $ \(RendererState n _ s') -> RendererState n True s'
+
+-- | Increase indent with 2 spaces.
+indent :: Renderer ()
+indent = modify $ \(RendererState n indentNext s) -> RendererState (n + 2) indentNext s
+
+-- | Decrease indent with 2 spaces.
+dedent :: Renderer ()
+dedent = modify $ \(RendererState n indentNext s) -> RendererState (n - 2) indentNext s
+
+-- | Indent the output of gen with 2 spaces.
+withIndent :: Renderer () -> Renderer ()
+withIndent gen = do
+  indent
+  gen
+  dedent
+
+instance Renderable ID where
+  render (ID s) = write s
+
+instance Renderable Attr where
+  render (Attr i1 i2) = writeln $ printf "%s = %s;" (show i1) (show i2)
+
+instance Renderable AttrList where
+  render = mapM_ render
+
+instance Renderable Port where
+  render (Port (Just id') c) =
+    write $ printf "%s:%s" (show id') (map toLower $ show c)
+  render (Port Nothing c) =
+    write $ map toLower $ show c
+
+instance Renderable NodeID where
+  render (NodeID id' (Just port)) = do
+    render id'
+    write ":"
+    render port
+  render (NodeID id' Nothing) = render id'
+
+instance Renderable Subgraph where
+  render (Subgraph id' []) =
+    writeln $ printf "subgraph %s {}" (show id')
+  render (Subgraph id' stmts) = do
+    writeln $ printf "subgraph %s {" (show id')
+    withIndent $ render stmts
+    writeln "}"
+
+instance Renderable EdgeOperator where
+  render Arrow = write " -> "
+  render Line = write " -- "
+
+instance Renderable EdgeOperand where
+  render (IDOperand nodeId) = render nodeId
+  render (SubgraphOperand sg) = render sg
+
+instance Renderable EdgeExpr where
+  render (EdgeExpr o1 operator o2) = do
+    render o1
+    render operator
+    render o2
+
+instance Renderable AttrStmtType where
+  render = write . map toLower . show
+
+inBrackets :: Renderer () -> Renderer ()
+inBrackets r = do
+  writeln " ["
+  withIndent r
+  writeln "]"
+
+instance Renderable Stmt where
+  render (NodeStmt id' []) = do
+    render id'
+    writeln ""
+  render (NodeStmt id' attrs) = do
+    render id'
+    inBrackets $ render attrs
+  render (EdgeStmt expr []) = do
+    render expr
+    writeln ";"
+  render (EdgeStmt expr attrs) = do
+    render expr
+    inBrackets $ render attrs
+  render (AttrStmt t []) = do
+    render t
+    writeln " []"
+  render (AttrStmt t attrs) = do
+    render t
+    inBrackets $ render attrs
+  render (EqualsStmt i1 i2) = do
+    render i1
+    write " = "
+    render i2
+    writeln ";"
+  render (SubgraphStmt sg) = render sg
+
+instance Renderable StmtList where
+  render = mapM_ render
+
+instance Renderable Graph where
+  render (Digraph id' stmts) = do
+    writeln $ printf "digraph %s {" (show id')
+    withIndent $ render stmts
+    writeln "}"
+
+renderString :: Renderer () -> String
+renderString r =
+  concat $ evalState (execWriterT r) (RendererState 0 False 0)
+
+renderGraphviz :: Graph -> String
+renderGraphviz = renderString . render
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
